From a0165cdcd561cf63f75512758803019de0642800 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:36:13 +0100 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20open=20the=20Files=20=E2=8B=AE=20men?= =?UTF-8?q?us=20beside=20their=20button,=20above=20it=20when=20there=20is?= =?UTF-8?q?=20no=20room=20below,=20never=20over=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ux-p2-pim-files-browser.test.tsx | 115 ++++++++++++++++++ components/files/file-browser.tsx | 9 +- components/files/file-item-parts.tsx | 13 -- components/files/files-header.tsx | 8 +- components/files/files-sidebar.tsx | 4 +- hooks/use-context-menu.ts | 6 +- 6 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 components/files/__tests__/ux-p2-pim-files-browser.test.tsx diff --git a/components/files/__tests__/ux-p2-pim-files-browser.test.tsx b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx new file mode 100644 index 000000000..7659f8847 --- /dev/null +++ b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FileBrowser } from '../file-browser'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Usability pass 2, Files (pim-5): the ⋮ of a row near the bottom opened its +// menu over itself, so a second click on the button hit "Delete". + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const res = (id: string, name: string, extra: Partial = {}): FileResource => ({ + id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra, +} as FileResource); +const notes = res('n', 'notes.txt'); +const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null }); + +export function renderBrowser(props: Partial> = {}, resources: FileResource[] = [docs, notes, budget]) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const h = { + onPreviewFile: vi.fn(), + onPreviewImage: vi.fn(), + onDownload: vi.fn(async () => {}), + onShowFileInfo: vi.fn(), + onSetSelection: vi.fn(), + onUploadFiles: vi.fn(async () => {}), + }; + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + {...h} + {...props} + />, + ); + return h; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +beforeEach(() => { + localStorage.clear(); + act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); }); +}); + +describe('the ⋮ of a row opens its menu beside the button, never over it (pim-5)', () => { + it('goes above a button near the bottom of the window', () => { + // 1280×720, as in the audit: the last row's ⋮ at y 536–568, a menu 357 px tall. + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720); + const button = { left: 1200, right: 1232, top: 536, bottom: 568, width: 32, height: 32 }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') return { x: 0, y: 0, left: 0, top: 0, right: 220, bottom: 357, width: 220, height: 357, toJSON: () => ({}) } as DOMRect; + if (this.hasAttribute('data-item-action')) return { x: button.left, y: button.top, ...button, toJSON: () => ({}) } as DOMRect; + return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + }); + renderBrowser(); + const kebab = document.querySelector('[data-resource="budget.xlsx"] [data-item-action][title="context_menu"]')!; + expect(kebab).toBeTruthy(); + fireEvent.click(kebab, { detail: 1, clientX: 1216, clientY: 552 }); + const menu = screen.getByRole('menu'); + const top = parseFloat(menu.style.top); + // Above the button: the menu ends before the button starts. + expect(top + 357).toBeLessThanOrEqual(button.top); + // The row stays marked while its menu is open. + expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index ffb6f7dce..6ce0ce0d2 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -28,7 +28,7 @@ import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar" import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header"; import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views"; import { FileDetailsPanel } from "@/components/files/file-details-panel"; -import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { AppMain } from "@/components/layout/app-landmarks"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; @@ -880,6 +880,7 @@ export function FileBrowser({ // ---- Context menu ---------------------------------------------------------- const openMenu = menu.openContextMenu; + const openMenuFrom = menu.openContextMenuFrom; const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => { // Acting on an item outside the selection makes it the selection, like a // desktop file manager. @@ -891,9 +892,9 @@ export function FileBrowser({ setKeyboardNav(false); const target = e.currentTarget as HTMLElement | null; // The "..." button opens the menu under itself; a right-click at the pointer. - if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource }); + if (e.type === "click" && target) openMenuFrom(target, { kind: "item", resource }, "end"); else openMenu(e, { kind: "item", resource }); - }, [selectedResources, onSetSelection, openMenu]); + }, [selectedResources, onSetSelection, openMenu, openMenuFrom]); const closeMenu = menu.closeContextMenu; const menuData = menu.contextMenu.data; @@ -1417,7 +1418,7 @@ export function FileBrowser({ size="icon-lg" className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg" aria-label={t("new")} - onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })} + onClick={(e) => menu.openContextMenuFrom(e.currentTarget, { kind: "new" }, "end")} > diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index a2364ca85..6367cc432 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title? ); } - -/** Open a shared context menu as a dropdown under a button. */ -export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent { - const rect = el.getBoundingClientRect(); - const rtl = typeof document !== "undefined" && document.dir === "rtl"; - const alignEnd = (align === "end") !== rtl; - return { - clientX: alignEnd ? rect.right - 200 : rect.left, - clientY: rect.bottom + 4, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as React.MouseEvent; -} diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index ba2dc457d..fd8a735e3 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -15,7 +15,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useMediaQuery } from "@/hooks/use-media-query"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { PluginActions } from "@/components/plugins/plugin-actions"; import type { FileResourceView } from "@/lib/plugin-types"; @@ -303,7 +303,7 @@ export function FilesHeader({ className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted" aria-label={t("show_path")} aria-haspopup="menu" - onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)} + onClick={(e) => overflowMenu.openContextMenuFrom(e.currentTarget, hidden)} > @@ -347,7 +347,7 @@ export function FilesHeader({ onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu(); - else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else sortMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > @@ -384,7 +384,7 @@ export function FilesHeader({ below="md" menuItems={(close) => ( <> - {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />} + {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />} {narrow && (viewMode === "list" ? { close(); onViewModeChange("grid"); }} /> : { close(); onViewModeChange("list"); }} />)} diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index d47ea17a5..00bf414fe 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -13,7 +13,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import { sharedFilePages, type FileResource } from "@/stores/file-store"; import { getFileIconByName } from "@/components/files/file-icons"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; const COLLAPSED_KEY = "files-sidebar-collapsed"; const RECENT_LIMIT = 8; @@ -297,7 +297,7 @@ export function FilesSidebar({ onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu(); - else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else newMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index e10f08648..7796434be 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -89,7 +89,11 @@ export function useContextMenu(): UseContextMenuReturn { }, []); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { - setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); + const position = anchorPosition(element, align); + // Focus goes back to it, and the row it's in stays marked while the menu is open. + position.trigger = focusReturnTarget(element); + position.opener = element.closest("[data-menu-row]") ?? element; + setContextMenu({ isOpen: true, position, data }); }, []); const closeContextMenu = useCallback(() => { From b70ab8a50cb789526c90ef2c56cc1615eae1955c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:56:09 +0100 Subject: [PATCH 2/6] feat: upload files dropped on the Files screen without asking, and add several .ics or .vcf in one import --- .../__tests__/file-drop-host.test.tsx | 22 ++++--- components/file-drop/file-drop-host.tsx | 4 ++ components/file-drop/file-drop-zones.tsx | 5 +- .../use-handoff-files-merge.test.tsx | 64 +++++++++++++++++++ hooks/use-handoff-files.ts | 14 +++- .../__tests__/zones-ux-p2-pim.test.ts | 54 ++++++++++++++++ lib/file-drop/merge-imports.ts | 48 ++++++++++++++ lib/file-drop/zones.ts | 23 ++++++- locales/ar/common.json | 20 ++++++ locales/ca/common.json | 20 ++++++ locales/cs/common.json | 20 ++++++ locales/da/common.json | 20 ++++++ locales/de/common.json | 20 ++++++ locales/en/common.json | 20 ++++++ locales/es/common.json | 20 ++++++ locales/et/common.json | 20 ++++++ locales/fa/common.json | 20 ++++++ locales/fr/common.json | 20 ++++++ locales/he/common.json | 20 ++++++ locales/hu/common.json | 20 ++++++ locales/it/common.json | 20 ++++++ locales/ja/common.json | 20 ++++++ locales/ko/common.json | 20 ++++++ locales/lv/common.json | 20 ++++++ locales/mn/common.json | 20 ++++++ locales/nb/common.json | 20 ++++++ locales/nl/common.json | 20 ++++++ locales/pl/common.json | 20 ++++++ locales/pt/common.json | 20 ++++++ locales/ro/common.json | 20 ++++++ locales/ru/common.json | 20 ++++++ locales/sk/common.json | 20 ++++++ locales/th/common.json | 20 ++++++ locales/tr/common.json | 20 ++++++ locales/uk/common.json | 20 ++++++ locales/vi/common.json | 20 ++++++ locales/zh-TW/common.json | 20 ++++++ locales/zh/common.json | 20 ++++++ .../__tests__/file-store-upload-into.test.ts | 63 ++++++++++++++++++ stores/file-store.ts | 23 +++++-- 40 files changed, 902 insertions(+), 18 deletions(-) create mode 100644 hooks/__tests__/use-handoff-files-merge.test.tsx create mode 100644 lib/file-drop/__tests__/zones-ux-p2-pim.test.ts create mode 100644 lib/file-drop/merge-imports.ts create mode 100644 stores/__tests__/file-store-upload-into.test.ts diff --git a/components/file-drop/__tests__/file-drop-host.test.tsx b/components/file-drop/__tests__/file-drop-host.test.tsx index 8b98a57d1..5847f6d84 100644 --- a/components/file-drop/__tests__/file-drop-host.test.tsx +++ b/components/file-drop/__tests__/file-drop-host.test.tsx @@ -146,16 +146,22 @@ describe('a drag from outside', () => { clear(); }); - it('on the Files screen, "Upload to " hands the files to that folder', async () => { + // pim-p2-6: on the Files screen a drop uploads where it lands, with no question; the + // list takes it (components/files). A file pasted there goes up into the folder in view. + it('on the Files screen, shows no choices and leaves the drop to the list', 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(); + try { + render(); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + act(() => requestFileChoice({ files: [file] })); + await flush(); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false }); + } finally { + clear(); + } }); it('from another screen, Files gets the files after the app goes there', async () => { diff --git a/components/file-drop/file-drop-host.tsx b/components/file-drop/file-drop-host.tsx index 1d1af9859..005552e15 100644 --- a/components/file-drop/file-drop-host.tsx +++ b/components/file-drop/file-drop-host.tsx @@ -19,6 +19,7 @@ import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read"; import { defaultZone, fileDropZones, + filesTakeDropsAsIs, filesWithinLimits, kindForName, type DropFiles, @@ -72,6 +73,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p popout: isPopoutPath(window.location.pathname), files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"), filesFolder: filesDropTarget()?.folder ?? null, + onFiles: filesDropTarget() !== null, calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"), contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"), contactsScreen: matchSurface(pathname)?.surface === "contacts", @@ -179,6 +181,8 @@ export function FileDropHost() { enter: (files) => { if (overlayBlocked()) return false; const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox); + // The Files screen takes the drop itself: the folder in view, or the folder row it lands on. + if (filesTakeDropsAsIs(place)) return false; const zones = fileDropZones(files, place); if (zones.length === 0) return false; setActive(null); diff --git a/components/file-drop/file-drop-zones.tsx b/components/file-drop/file-drop-zones.tsx index 01843b5a4..41ec54163 100644 --- a/components/file-drop/file-drop-zones.tsx +++ b/components/file-drop/file-drop-zones.tsx @@ -19,14 +19,15 @@ const ICONS: Record> = { export function useZoneText() { const t = useTranslations("fileDrop"); + const tAll = useTranslations("ux_p2_pim_files"); const label = (zone: FileDropZone): string => { switch (zone.id) { case "attach-here": return t("zone_attach_here"); case "insert-inline": return t("zone_insert_inline"); case "new-message": return t("zone_new_message"); case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files"); - case "calendar": return t("zone_calendar"); - case "contacts": return t("zone_contacts"); + case "calendar": return zone.several ? tAll("zone_calendar_all") : t("zone_calendar"); + case "contacts": return zone.several ? tAll("zone_contacts_all") : t("zone_contacts"); case "open-eml": return t("zone_open_eml"); case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" }); } diff --git a/hooks/__tests__/use-handoff-files-merge.test.tsx b/hooks/__tests__/use-handoff-files-merge.test.tsx new file mode 100644 index 000000000..f3e9735a8 --- /dev/null +++ b/hooks/__tests__/use-handoff-files-merge.test.tsx @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useHandoffFiles } from '../use-handoff-files'; +import { handOffFiles, takeHandoff } from '@/lib/launch-handoff'; +import { mergeCalendarTexts, mergeVCardTexts } from '@/lib/file-drop/merge-imports'; + +// pim-p2-10: several .ics or .vcf dropped together go into one import list. + +const ics = (uid: string, title: string) => [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Test//EN', + 'BEGIN:VEVENT', `UID:${uid}`, `SUMMARY:${title}`, 'DTSTART:20261110T090000Z', 'END:VEVENT', + 'END:VCALENDAR', '', +].join('\r\n'); +const vcf = (name: string) => ['BEGIN:VCARD', 'VERSION:3.0', `FN:${name}`, 'END:VCARD', ''].join('\r\n'); + +afterEach(() => { + for (const kind of ['calendar', 'contacts'] as const) while (takeHandoff(kind)) { /* drain */ } +}); + +describe('merging calendars and cards', () => { + it('puts every event of two calendars into one VCALENDAR', () => { + const merged = mergeCalendarTexts([ics('a', 'First'), ics('b', 'Second')]); + expect(merged.match(/BEGIN:VCALENDAR/g)).toHaveLength(1); + expect(merged.match(/END:VCALENDAR/g)).toHaveLength(1); + expect(merged.match(/BEGIN:VEVENT/g)).toHaveLength(2); + expect(merged).toContain('SUMMARY:First'); + expect(merged).toContain('SUMMARY:Second'); + expect(merged.match(/VERSION:2.0/g)).toHaveLength(1); + }); + + it('puts the cards one after the other', () => { + const merged = mergeVCardTexts([vcf('Anna'), vcf('Ben')]); + expect(merged.match(/BEGIN:VCARD/g)).toHaveLength(2); + }); +}); + +describe('useHandoffFiles', () => { + it('gives two dropped .ics as one file to the import', async () => { + const { result } = renderHook(() => useHandoffFiles('calendar', true)); + act(() => { + handOffFiles([ + new File([ics('a', 'First')], 'first.ics', { type: 'text/calendar' }), + new File([ics('b', 'Second')], 'second.ics', { type: 'text/calendar' }), + ], 'calendar'); + }); + await waitFor(() => expect(result.current.file).not.toBeNull()); + const text = await result.current.file!.text(); + expect(text).toContain('SUMMARY:First'); + expect(text).toContain('SUMMARY:Second'); + act(() => result.current.done()); + expect(result.current.file).toBeNull(); + }); + + it('gives three .vcf as one file', async () => { + const { result } = renderHook(() => useHandoffFiles('contacts', true)); + act(() => { + handOffFiles(['Anna', 'Ben', 'Cleo'].map((n) => new File([vcf(n)], `${n}.vcf`, { type: 'text/vcard' })), 'contacts'); + }); + await waitFor(() => expect(result.current.file).not.toBeNull()); + expect((await result.current.file!.text()).match(/BEGIN:VCARD/g)).toHaveLength(3); + act(() => result.current.done()); + expect(result.current.file).toBeNull(); + }); +}); diff --git a/hooks/use-handoff-files.ts b/hooks/use-handoff-files.ts index 9ef0eea8e..1bb3e39c8 100644 --- a/hooks/use-handoff-files.ts +++ b/hooks/use-handoff-files.ts @@ -2,11 +2,14 @@ import { useCallback, useEffect, useState } from "react"; import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff"; +import { mergeImportFiles } from "@/lib/file-drop/merge-imports"; /** * The files handed to this part of the app (lib/launch-handoff.ts), one at a * time. Nothing is taken while `active` is false, for a surface that isn't - * ready for them yet. `done` moves on to the next file. + * ready for them yet. `done` moves on to the next file. Several calendars or + * address cards handed over together come as one file, so one import lists + * them all (pim-p2-10). */ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } { const [files, setFiles] = useState([]); @@ -16,7 +19,14 @@ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: Fil const take = () => { for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) { const received = handoff.files; - if (received.length > 0) setFiles((waiting) => [...waiting, ...received]); + if (received.length > 1 && (kind === "calendar" || kind === "contacts")) { + void mergeImportFiles(kind, received).then( + (merged) => setFiles((waiting) => [...waiting, merged]), + () => setFiles((waiting) => [...waiting, ...received]), + ); + } else if (received.length > 0) { + setFiles((waiting) => [...waiting, ...received]); + } } }; take(); diff --git a/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts b/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts new file mode 100644 index 000000000..31634fb3d --- /dev/null +++ b/lib/file-drop/__tests__/zones-ux-p2-pim.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { fileDropZones, filesTakeDropsAsIs, type DropFiles, type DropPlace } from '../zones'; + +// Usability pass 2 (pim-p2-6, pim-p2-10). + +const place = (patch: Partial = {}): DropPlace => ({ + composer: false, + inlineImages: false, + popout: false, + files: true, + filesFolder: null, + calendar: true, + contacts: true, + importFolder: null, + offline: false, + keepsOffline: false, + maxUploadBytes: 0, + maxMessageBytes: 0, + ...patch, +}); + +const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null }); + +describe('on the Files screen a drop uploads where you are, with no question (pim-p2-6)', () => { + it('has only the upload into the folder in view, for any kind of file', () => { + for (const kinds of [['other'], ['calendar'], ['eml', 'eml'], ['image']] as DropFiles['kinds'][]) { + expect(fileDropZones(drag(kinds), place({ onFiles: true, filesFolder: 'Documents' }))).toEqual([{ id: 'upload', folder: 'Documents' }]); + } + expect(filesTakeDropsAsIs(place({ onFiles: true }))).toBe(true); + }); + + it('still asks with a composer open, and on every other screen', () => { + expect(filesTakeDropsAsIs(place({ onFiles: true, composer: true }))).toBe(false); + expect(fileDropZones(drag(['other']), place({ onFiles: true, composer: true })).map((z) => z.id)).toEqual(['attach-here', 'new-message', 'upload']); + expect(filesTakeDropsAsIs(place())).toBe(false); + expect(fileDropZones(drag(['other']), place()).map((z) => z.id)).toEqual(['new-message', 'upload']); + }); +}); + +describe('several .ics or .vcf can be added together (pim-p2-10)', () => { + it('offers "Add all to calendar" for two .ics', () => { + const zones = fileDropZones(drag(['calendar', 'calendar']), place()); + expect(zones[0]).toEqual({ id: 'calendar', several: true }); + }); + + it('offers "Add all to contacts" for three .vcf', () => { + const zones = fileDropZones(drag(['contacts', 'contacts', 'contacts']), place()); + expect(zones[0]).toEqual({ id: 'contacts', several: true }); + }); + + it('keeps the plain choice for one file', () => { + expect(fileDropZones(drag(['calendar']), place())[0]).toEqual({ id: 'calendar' }); + }); +}); diff --git a/lib/file-drop/merge-imports.ts b/lib/file-drop/merge-imports.ts new file mode 100644 index 000000000..bcf50977b --- /dev/null +++ b/lib/file-drop/merge-imports.ts @@ -0,0 +1,48 @@ +/** + * Several .ics or .vcf files added at once go into one import list (pim-p2-10): the import + * dialogs read one file, so the files become one. vCards follow each other in a file as they + * are. Calendars become one VCALENDAR: the first file's own lines (VERSION, PRODID, …), then + * every file's components (VEVENT, VTODO, VTIMEZONE), in order. + */ + +const CRLF = '\r\n'; + +function lines(text: string): string[] { + return text.replace(/\r\n?/g, '\n').split('\n'); +} + +/** The component blocks of one VCALENDAR, and the calendar's own lines before the first of them. */ +function calendarParts(text: string): { head: string[]; components: string[] } { + const all = lines(text); + const begin = all.findIndex((line) => /^BEGIN:VCALENDAR\s*$/i.test(line)); + let end = -1; + for (let i = all.length - 1; i >= 0; i--) { + if (/^END:VCALENDAR\s*$/i.test(all[i])) { end = i; break; } + } + if (begin === -1 || end <= begin) return { head: [], components: [] }; + const inner = all.slice(begin + 1, end); + const first = inner.findIndex((line) => /^BEGIN:/i.test(line)); + return first === -1 + ? { head: inner.filter(Boolean), components: [] } + : { head: inner.slice(0, first).filter(Boolean), components: inner.slice(first).filter(Boolean) }; +} + +export function mergeCalendarTexts(texts: string[]): string { + const parts = texts.map(calendarParts); + const head = parts.find((part) => part.head.length > 0)?.head ?? ['VERSION:2.0']; + return ['BEGIN:VCALENDAR', ...head, ...parts.flatMap((part) => part.components), 'END:VCALENDAR', ''].join(CRLF); +} + +export function mergeVCardTexts(texts: string[]): string { + return texts.map((text) => text.replace(/\r\n?/g, '\n').trim()).filter(Boolean).join('\n').replace(/\n/g, CRLF) + CRLF; +} + +/** One file holding them all, named after the first; a single file stays as it is. */ +export async function mergeImportFiles(kind: 'calendar' | 'contacts', files: File[]): Promise { + if (files.length === 1) return files[0]; + const texts = await Promise.all(files.map((file) => file.text())); + const [first] = files; + return kind === 'calendar' + ? new File([mergeCalendarTexts(texts)], first.name, { type: 'text/calendar' }) + : new File([mergeVCardTexts(texts)], first.name, { type: 'text/vcard' }); +} diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts index 57ee17c84..da04a14d3 100644 --- a/lib/file-drop/zones.ts +++ b/lib/file-drop/zones.ts @@ -38,6 +38,12 @@ export interface DropPlace { files: boolean; /** On the Files screen, the folder in view, when files can go there. */ filesFolder: string | null; + /** + * The Files screen is on view and takes files where it is. With no composer open a drop + * there uploads to the folder in view (or the folder row it lands on) without asking, + * as Explorer and Finder do; the choices are for the other screens. + */ + onFiles?: boolean; calendar: boolean; contacts: boolean; /** The Contacts screen is open: a CSV dropped there is a contacts import. */ @@ -61,6 +67,8 @@ export type FileDropNote = export interface FileDropZone { id: FileDropZoneId; + /** Calendar and contacts: several files, all added in one import ("Add all to calendar"). */ + several?: boolean; /** Names the folder for `upload` on the Files screen and for `import-eml`. */ folder?: string; note?: FileDropNote; @@ -116,6 +124,11 @@ function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone { export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] { const kind = sharedKind(files); const one = files.count === 1; + // Files dropped or pasted on the Files screen go up into the folder in view, no question. + if (filesTakeDropsAsIs(place)) { + const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined; + return [serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })]; + } const main: FileDropZone[] = []; if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) }); if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) }); @@ -131,8 +144,9 @@ export function fileDropZones(files: DropFiles, place: DropPlace): 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' }); + // Several at once go into one import list (pim-p2-10). + if (kind === 'calendar' && place.calendar) special.push({ id: 'calendar', ...(one || files.count === null ? {} : { several: true }) }); + if (kind === 'contacts' && place.contacts) special.push({ id: 'contacts', ...(one || files.count === null ? {} : { several: true }) }); if (kind === 'csv' && one && place.contacts && place.contactsScreen) special.push({ id: 'contacts' }); } @@ -143,6 +157,11 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[ return [...attachHere, ...kept, ...rest]; } +/** The Files screen takes a drop as it is: on view, a folder that takes files, no composer open, not a pop-out. */ +export function filesTakeDropsAsIs(place: Pick): boolean { + return !!place.onFiles && place.files && !place.composer && !place.popout; +} + /** What the files may still do, once they're in hand: those too big for the zone stay out. */ export function filesWithinLimits( zone: FileDropZoneId, diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..3b5e650bf 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "شاركت {name} معك", "share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "إضافة الكل إلى التقويم", + "zone_contacts_all": "إضافة الكل إلى جهات الاتصال", + "save_to_files": "حفظ في الملفات", + "saved": "تم حفظ {name} في الملفات.", + "saved_copy": "تم الحفظ في الملفات باسم {name}.", + "save_failed": "تعذّر حفظ {name} في الملفات.", + "changed_on_server": "تم تغيير {name} في الملفات بعد أن فتحته.", + "gone_on_server": "لم يعد {name} موجودًا في الملفات.", + "keep_both": "الاحتفاظ بالاثنين", + "replace": "استبدال", + "cancel": "إلغاء", + "save_as_new": "حفظ كملف جديد", + "import_to_mail": "استيراد إلى مجلد بريد…", + "import_to_mail_title": "استيراد إلى مجلد بريد", + "imported_to_mail": "تم الاستيراد إلى {folder}.", + "import_to_mail_failed": "تعذّر استيراد {name}.", + "import_contacts": "استيراد إلى جهات الاتصال…", + "add_to_calendar": "إضافة إلى التقويم…" + }, "smime": { "your_certificates": "شهاداتك", "your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..7c1a615ed 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "He compartit {name} amb tu", "share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Afegeix-ho tot al calendari", + "zone_contacts_all": "Afegeix-ho tot als contactes", + "save_to_files": "Desa a Fitxers", + "saved": "{name} s'ha desat a Fitxers.", + "saved_copy": "S'ha desat a Fitxers com a {name}.", + "save_failed": "No s'ha pogut desar {name} a Fitxers.", + "changed_on_server": "{name} ha canviat a Fitxers després que l'obrissis.", + "gone_on_server": "{name} ja no és a Fitxers.", + "keep_both": "Conserva’ls tots dos", + "replace": "Substitueix", + "cancel": "Cancel·la", + "save_as_new": "Desa com a fitxer nou", + "import_to_mail": "Importa a una carpeta de correu…", + "import_to_mail_title": "Importa a una carpeta de correu", + "imported_to_mail": "S'ha importat a {folder}.", + "import_to_mail_failed": "No s'ha pogut importar {name}.", + "import_contacts": "Importa als contactes…", + "add_to_calendar": "Afegeix al calendari…" + }, "smime": { "your_certificates": "Els vostres certificats", "your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..f7b9906b5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Sdílel jsem s vámi {name}", "share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Přidat vše do kalendáře", + "zone_contacts_all": "Přidat vše do kontaktů", + "save_to_files": "Uložit do Souborů", + "saved": "{name} uloženo do Souborů.", + "saved_copy": "Uloženo do Souborů jako {name}.", + "save_failed": "{name} se nepodařilo uložit do Souborů.", + "changed_on_server": "{name} se v Souborech změnil poté, co jste ho otevřeli.", + "gone_on_server": "{name} už v Souborech není.", + "keep_both": "Ponechat obojí", + "replace": "Nahradit", + "cancel": "Zrušit", + "save_as_new": "Uložit jako nový soubor", + "import_to_mail": "Importovat do složky pošty…", + "import_to_mail_title": "Importovat do složky pošty", + "imported_to_mail": "Importováno do {folder}.", + "import_to_mail_failed": "{name} se nepodařilo importovat.", + "import_contacts": "Importovat do kontaktů…", + "add_to_calendar": "Přidat do kalendáře…" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..268a6b5ab 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Jeg har delt {name} med dig", "share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Føj alle til kalender", + "zone_contacts_all": "Føj alle til kontakter", + "save_to_files": "Gem i Filer", + "saved": "{name} er gemt i Filer.", + "saved_copy": "Gemt i Filer som {name}.", + "save_failed": "{name} kunne ikke gemmes i Filer.", + "changed_on_server": "{name} blev ændret i Filer, efter du åbnede den.", + "gone_on_server": "{name} findes ikke længere i Filer.", + "keep_both": "Behold begge", + "replace": "Erstat", + "cancel": "Annuller", + "save_as_new": "Gem som ny fil", + "import_to_mail": "Importér til en mailmappe…", + "import_to_mail_title": "Importér til en mailmappe", + "imported_to_mail": "Importeret til {folder}.", + "import_to_mail_failed": "{name} kunne ikke importeres.", + "import_contacts": "Importér til kontakter…", + "add_to_calendar": "Føj til kalender…" + }, "smime": { "your_certificates": "Dine certifikater", "your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..7dcbb4c5f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Ich habe {name} für Sie freigegeben", "share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Alle zum Kalender hinzufügen", + "zone_contacts_all": "Alle zu Kontakten hinzufügen", + "save_to_files": "In Dateien speichern", + "saved": "{name} in Dateien gespeichert.", + "saved_copy": "In Dateien gespeichert als {name}.", + "save_failed": "{name} konnte nicht in Dateien gespeichert werden.", + "changed_on_server": "{name} wurde in Dateien geändert, nachdem Sie die Datei geöffnet haben.", + "gone_on_server": "{name} ist nicht mehr in Dateien.", + "keep_both": "Beide behalten", + "replace": "Ersetzen", + "cancel": "Abbrechen", + "save_as_new": "Als neue Datei speichern", + "import_to_mail": "In einen Mailordner importieren…", + "import_to_mail_title": "In einen Mailordner importieren", + "imported_to_mail": "In {folder} importiert.", + "import_to_mail_failed": "{name} konnte nicht importiert werden.", + "import_contacts": "In Kontakte importieren…", + "add_to_calendar": "Zum Kalender hinzufügen…" + }, "smime": { "your_certificates": "Ihre Zertifikate", "your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..e6da2a3a5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "I shared {name} with you", "share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Add all to calendar", + "zone_contacts_all": "Add all to contacts", + "save_to_files": "Save to Files", + "saved": "{name} saved to Files.", + "saved_copy": "Saved to Files as {name}.", + "save_failed": "{name} could not be saved to Files.", + "changed_on_server": "{name} was changed in Files after you opened it.", + "gone_on_server": "{name} is no longer in Files.", + "keep_both": "Keep both", + "replace": "Replace", + "cancel": "Cancel", + "save_as_new": "Save as a new file", + "import_to_mail": "Import to a mail folder…", + "import_to_mail_title": "Import to a mail folder", + "imported_to_mail": "Imported into {folder}.", + "import_to_mail_failed": "{name} could not be imported.", + "import_contacts": "Import into contacts…", + "add_to_calendar": "Add to calendar…" + }, "smime": { "your_certificates": "Your Certificates", "your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..58d307484 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "He compartido {name} contigo", "share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Añadir todo al calendario", + "zone_contacts_all": "Añadir todo a contactos", + "save_to_files": "Guardar en Archivos", + "saved": "{name} se guardó en Archivos.", + "saved_copy": "Guardado en Archivos como {name}.", + "save_failed": "No se pudo guardar {name} en Archivos.", + "changed_on_server": "{name} cambió en Archivos después de que lo abrieras.", + "gone_on_server": "{name} ya no está en Archivos.", + "keep_both": "Conservar ambos", + "replace": "Reemplazar", + "cancel": "Cancelar", + "save_as_new": "Guardar como archivo nuevo", + "import_to_mail": "Importar a una carpeta de correo…", + "import_to_mail_title": "Importar a una carpeta de correo", + "imported_to_mail": "Importado en {folder}.", + "import_to_mail_failed": "No se pudo importar {name}.", + "import_contacts": "Importar a contactos…", + "add_to_calendar": "Añadir al calendario…" + }, "smime": { "your_certificates": "Tus certificados", "your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..f2cbda852 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Jagasin sinuga faili {name}", "share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Lisa kõik kalendrisse", + "zone_contacts_all": "Lisa kõik kontaktidesse", + "save_to_files": "Salvesta kausta Failid", + "saved": "{name} salvestati kausta Failid.", + "saved_copy": "Salvestati kausta Failid nimega {name}.", + "save_failed": "Faili {name} ei saanud kausta Failid salvestada.", + "changed_on_server": "Faili {name} muudeti kaustas Failid pärast seda, kui selle avasid.", + "gone_on_server": "Faili {name} pole enam kaustas Failid.", + "keep_both": "Jäta mõlemad", + "replace": "Asenda", + "cancel": "Tühista", + "save_as_new": "Salvesta uue failina", + "import_to_mail": "Impordi meilikausta…", + "import_to_mail_title": "Impordi meilikausta", + "imported_to_mail": "Imporditi kausta {folder}.", + "import_to_mail_failed": "Faili {name} ei saanud importida.", + "import_contacts": "Impordi kontaktidesse…", + "add_to_calendar": "Lisa kalendrisse…" + }, "smime": { "your_certificates": "Sinu sertifikaadid", "your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..0d4ecc648 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "{name} را با شما به اشتراک گذاشتم", "share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. می‌توانید آن را اینجا باز کنید:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "افزودن همه به تقویم", + "zone_contacts_all": "افزودن همه به مخاطبان", + "save_to_files": "ذخیره در فایل‌ها", + "saved": "{name} در فایل‌ها ذخیره شد.", + "saved_copy": "با نام {name} در فایل‌ها ذخیره شد.", + "save_failed": "ذخیرهٔ {name} در فایل‌ها ممکن نشد.", + "changed_on_server": "{name} پس از اینکه آن را باز کردید در فایل‌ها تغییر کرد.", + "gone_on_server": "{name} دیگر در فایل‌ها نیست.", + "keep_both": "نگه‌داشتن هر دو", + "replace": "جایگزینی", + "cancel": "انصراف", + "save_as_new": "ذخیره به‌عنوان فایل جدید", + "import_to_mail": "وارد کردن به یک پوشهٔ ایمیل…", + "import_to_mail_title": "وارد کردن به یک پوشهٔ ایمیل", + "imported_to_mail": "به {folder} وارد شد.", + "import_to_mail_failed": "وارد کردن {name} ممکن نشد.", + "import_contacts": "وارد کردن به مخاطبان…", + "add_to_calendar": "افزودن به تقویم…" + }, "smime": { "your_certificates": "گواهی‌های شما", "your_certificates_desc": "مدیریت گواهی‌های S/MIME", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..c70329d17 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "J’ai partagé {name} avec vous", "share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Tout ajouter au calendrier", + "zone_contacts_all": "Tout ajouter aux contacts", + "save_to_files": "Enregistrer dans Fichiers", + "saved": "{name} enregistré dans Fichiers.", + "saved_copy": "Enregistré dans Fichiers sous {name}.", + "save_failed": "Impossible d'enregistrer {name} dans Fichiers.", + "changed_on_server": "{name} a été modifié dans Fichiers après que vous l'avez ouvert.", + "gone_on_server": "{name} n'est plus dans Fichiers.", + "keep_both": "Conserver les deux", + "replace": "Remplacer", + "cancel": "Annuler", + "save_as_new": "Enregistrer comme nouveau fichier", + "import_to_mail": "Importer dans un dossier de messagerie…", + "import_to_mail_title": "Importer dans un dossier de messagerie", + "imported_to_mail": "Importé dans {folder}.", + "import_to_mail_failed": "Impossible d'importer {name}.", + "import_contacts": "Importer dans les contacts…", + "add_to_calendar": "Ajouter au calendrier…" + }, "smime": { "your_certificates": "Vos certificats", "your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..4f55bdb66 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5412,6 +5412,26 @@ "share_mail_subject": "שיתפתי איתך את {name}", "share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "הוספת הכול ללוח השנה", + "zone_contacts_all": "הוספת הכול לאנשי הקשר", + "save_to_files": "שמירה בקבצים", + "saved": "{name} נשמר בקבצים.", + "saved_copy": "נשמר בקבצים בשם {name}.", + "save_failed": "לא ניתן היה לשמור את {name} בקבצים.", + "changed_on_server": "{name} השתנה בקבצים אחרי שפתחת אותו.", + "gone_on_server": "{name} כבר לא נמצא בקבצים.", + "keep_both": "שמירת שניהם", + "replace": "החלפה", + "cancel": "ביטול", + "save_as_new": "שמירה כקובץ חדש", + "import_to_mail": "ייבוא לתיקיית דואר…", + "import_to_mail_title": "ייבוא לתיקיית דואר", + "imported_to_mail": "יובא אל {folder}.", + "import_to_mail_failed": "לא ניתן היה לייבא את {name}.", + "import_contacts": "ייבוא לאנשי הקשר…", + "add_to_calendar": "הוספה ללוח השנה…" + }, "smime": { "your_certificates": "התעודות שלך", "your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..fbada8cae 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Megosztottam veled: {name}", "share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Összes hozzáadása a naptárhoz", + "zone_contacts_all": "Összes hozzáadása a névjegyekhez", + "save_to_files": "Mentés a Fájlokba", + "saved": "{name} mentve a Fájlokba.", + "saved_copy": "Mentve a Fájlokba {name} néven.", + "save_failed": "{name} nem menthető a Fájlokba.", + "changed_on_server": "{name} megváltozott a Fájlokban, miután megnyitottad.", + "gone_on_server": "{name} már nincs a Fájlokban.", + "keep_both": "Mindkettő megtartása", + "replace": "Csere", + "cancel": "Mégse", + "save_as_new": "Mentés új fájlként", + "import_to_mail": "Importálás levélmappába…", + "import_to_mail_title": "Importálás levélmappába", + "imported_to_mail": "Importálva ide: {folder}.", + "import_to_mail_failed": "{name} nem importálható.", + "import_contacts": "Importálás a névjegyekbe…", + "add_to_calendar": "Hozzáadás a naptárhoz…" + }, "smime": { "your_certificates": "Tanúsítványaid", "your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..de725e2c7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Ho condiviso {name} con te", "share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Aggiungi tutto al calendario", + "zone_contacts_all": "Aggiungi tutto ai contatti", + "save_to_files": "Salva in File", + "saved": "{name} salvato in File.", + "saved_copy": "Salvato in File come {name}.", + "save_failed": "Impossibile salvare {name} in File.", + "changed_on_server": "{name} è stato modificato in File dopo che lo hai aperto.", + "gone_on_server": "{name} non è più in File.", + "keep_both": "Mantieni entrambi", + "replace": "Sostituisci", + "cancel": "Annulla", + "save_as_new": "Salva come nuovo file", + "import_to_mail": "Importa in una cartella di posta…", + "import_to_mail_title": "Importa in una cartella di posta", + "imported_to_mail": "Importato in {folder}.", + "import_to_mail_failed": "Impossibile importare {name}.", + "import_contacts": "Importa nei contatti…", + "add_to_calendar": "Aggiungi al calendario…" + }, "smime": { "your_certificates": "I tuoi certificati", "your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..9a310f20c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "{name} を共有しました", "share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "すべてカレンダーに追加", + "zone_contacts_all": "すべて連絡先に追加", + "save_to_files": "ファイルに保存", + "saved": "{name} をファイルに保存しました。", + "saved_copy": "{name} としてファイルに保存しました。", + "save_failed": "{name} をファイルに保存できませんでした。", + "changed_on_server": "{name} は開いた後にファイルで変更されました。", + "gone_on_server": "{name} はファイルにもうありません。", + "keep_both": "両方残す", + "replace": "置き換える", + "cancel": "キャンセル", + "save_as_new": "新しいファイルとして保存", + "import_to_mail": "メールフォルダーにインポート…", + "import_to_mail_title": "メールフォルダーにインポート", + "imported_to_mail": "{folder} にインポートしました。", + "import_to_mail_failed": "{name} をインポートできませんでした。", + "import_contacts": "連絡先にインポート…", + "add_to_calendar": "カレンダーに追加…" + }, "smime": { "your_certificates": "あなたの証明書", "your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..a184a2de9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "{name}을(를) 공유했습니다", "share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "모두 캘린더에 추가", + "zone_contacts_all": "모두 연락처에 추가", + "save_to_files": "파일에 저장", + "saved": "{name}을(를) 파일에 저장했습니다.", + "saved_copy": "파일에 {name}(으)로 저장했습니다.", + "save_failed": "{name}을(를) 파일에 저장하지 못했습니다.", + "changed_on_server": "{name}이(가) 연 뒤에 파일에서 변경되었습니다.", + "gone_on_server": "{name}이(가) 더 이상 파일에 없습니다.", + "keep_both": "둘 다 유지", + "replace": "바꾸기", + "cancel": "취소", + "save_as_new": "새 파일로 저장", + "import_to_mail": "메일 폴더로 가져오기…", + "import_to_mail_title": "메일 폴더로 가져오기", + "imported_to_mail": "{folder}(으)로 가져왔습니다.", + "import_to_mail_failed": "{name}을(를) 가져오지 못했습니다.", + "import_contacts": "연락처로 가져오기…", + "add_to_calendar": "캘린더에 추가…" + }, "smime": { "your_certificates": "내 인증서", "your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..c7c712dd6 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Es koplietoju ar jums {name}", "share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Pievienot visus kalendāram", + "zone_contacts_all": "Pievienot visus kontaktiem", + "save_to_files": "Saglabāt failos", + "saved": "{name} saglabāts failos.", + "saved_copy": "Saglabāts failos kā {name}.", + "save_failed": "{name} neizdevās saglabāt failos.", + "changed_on_server": "{name} tika mainīts failos pēc tam, kad to atvērāt.", + "gone_on_server": "{name} vairs nav failos.", + "keep_both": "Paturēt abus", + "replace": "Aizstāt", + "cancel": "Atcelt", + "save_as_new": "Saglabāt kā jaunu failu", + "import_to_mail": "Importēt pasta mapē…", + "import_to_mail_title": "Importēt pasta mapē", + "imported_to_mail": "Importēts mapē {folder}.", + "import_to_mail_failed": "{name} neizdevās importēt.", + "import_contacts": "Importēt kontaktos…", + "add_to_calendar": "Pievienot kalendāram…" + }, "smime": { "your_certificates": "Jūsu sertifikāti", "your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..3d4438dac 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Би {name}-г тантай хуваалцлаа", "share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Бүгдийг хуанлид нэмэх", + "zone_contacts_all": "Бүгдийг харилцагчид нэмэх", + "save_to_files": "Файлд хадгалах", + "saved": "{name}-г Файлд хадгаллаа.", + "saved_copy": "Файлд {name} нэрээр хадгаллаа.", + "save_failed": "{name}-г Файлд хадгалж чадсангүй.", + "changed_on_server": "{name}-г нээсний дараа Файлд өөрчилсөн байна.", + "gone_on_server": "{name} Файлд байхгүй болсон.", + "keep_both": "Хоёуланг нь хадгалах", + "replace": "Солих", + "cancel": "Цуцлах", + "save_as_new": "Шинэ файлаар хадгалах", + "import_to_mail": "Шуудангийн хавтас руу импортлох…", + "import_to_mail_title": "Шуудангийн хавтас руу импортлох", + "imported_to_mail": "{folder} руу импортлолоо.", + "import_to_mail_failed": "{name}-г импортлож чадсангүй.", + "import_contacts": "Харилцагчид импортлох…", + "add_to_calendar": "Хуанлид нэмэх…" + }, "smime": { "your_certificates": "Таны сертификатууд", "your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..2b79c6511 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Jeg har delt {name} med deg", "share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Legg alle til i kalenderen", + "zone_contacts_all": "Legg alle til i kontakter", + "save_to_files": "Lagre i Filer", + "saved": "{name} er lagret i Filer.", + "saved_copy": "Lagret i Filer som {name}.", + "save_failed": "Kunne ikke lagre {name} i Filer.", + "changed_on_server": "{name} ble endret i Filer etter at du åpnet den.", + "gone_on_server": "{name} finnes ikke lenger i Filer.", + "keep_both": "Behold begge", + "replace": "Erstatt", + "cancel": "Avbryt", + "save_as_new": "Lagre som ny fil", + "import_to_mail": "Importer til en e-postmappe…", + "import_to_mail_title": "Importer til en e-postmappe", + "imported_to_mail": "Importert til {folder}.", + "import_to_mail_failed": "Kunne ikke importere {name}.", + "import_contacts": "Importer til kontakter…", + "add_to_calendar": "Legg til i kalenderen…" + }, "smime": { "your_certificates": "Dine sertifikater", "your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..ba3ccefc4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Ik heb {name} met je gedeeld", "share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Alles aan agenda toevoegen", + "zone_contacts_all": "Alles aan contacten toevoegen", + "save_to_files": "Opslaan in Bestanden", + "saved": "{name} opgeslagen in Bestanden.", + "saved_copy": "Opgeslagen in Bestanden als {name}.", + "save_failed": "{name} kon niet in Bestanden worden opgeslagen.", + "changed_on_server": "{name} is in Bestanden gewijzigd nadat je het opende.", + "gone_on_server": "{name} staat niet meer in Bestanden.", + "keep_both": "Beide behouden", + "replace": "Vervangen", + "cancel": "Annuleren", + "save_as_new": "Opslaan als nieuw bestand", + "import_to_mail": "Importeren in een mailmap…", + "import_to_mail_title": "Importeren in een mailmap", + "imported_to_mail": "Geïmporteerd in {folder}.", + "import_to_mail_failed": "{name} kon niet worden geïmporteerd.", + "import_contacts": "Importeren in contacten…", + "add_to_calendar": "Aan agenda toevoegen…" + }, "smime": { "your_certificates": "Uw certificaten", "your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..de10ba5b8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Udostępniłem Ci {name}", "share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Dodaj wszystko do kalendarza", + "zone_contacts_all": "Dodaj wszystko do kontaktów", + "save_to_files": "Zapisz w Plikach", + "saved": "Zapisano {name} w Plikach.", + "saved_copy": "Zapisano w Plikach jako {name}.", + "save_failed": "Nie udało się zapisać {name} w Plikach.", + "changed_on_server": "{name} zmieniono w Plikach po tym, jak go otworzono.", + "gone_on_server": "{name} nie ma już w Plikach.", + "keep_both": "Zachowaj oba", + "replace": "Zastąp", + "cancel": "Anuluj", + "save_as_new": "Zapisz jako nowy plik", + "import_to_mail": "Importuj do folderu poczty…", + "import_to_mail_title": "Importuj do folderu poczty", + "imported_to_mail": "Zaimportowano do {folder}.", + "import_to_mail_failed": "Nie udało się zaimportować {name}.", + "import_contacts": "Importuj do kontaktów…", + "add_to_calendar": "Dodaj do kalendarza…" + }, "smime": { "your_certificates": "Twoje certyfikaty", "your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..0b4331220 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Compartilhei {name} com você", "share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Adicionar tudo ao calendário", + "zone_contacts_all": "Adicionar tudo aos contactos", + "save_to_files": "Guardar em Arquivos", + "saved": "{name} guardado em Arquivos.", + "saved_copy": "Guardado em Arquivos como {name}.", + "save_failed": "Não foi possível guardar {name} em Arquivos.", + "changed_on_server": "{name} foi alterado em Arquivos depois de o abrir.", + "gone_on_server": "{name} já não está em Arquivos.", + "keep_both": "Manter ambos", + "replace": "Substituir", + "cancel": "Cancelar", + "save_as_new": "Guardar como novo ficheiro", + "import_to_mail": "Importar para uma pasta de correio…", + "import_to_mail_title": "Importar para uma pasta de correio", + "imported_to_mail": "Importado para {folder}.", + "import_to_mail_failed": "Não foi possível importar {name}.", + "import_contacts": "Importar para os contactos…", + "add_to_calendar": "Adicionar ao calendário…" + }, "smime": { "your_certificates": "Seus certificados", "your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..6c2c87e60 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Am partajat {name} cu tine", "share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Adăugați tot în calendar", + "zone_contacts_all": "Adăugați tot la contacte", + "save_to_files": "Salvați în Fișiere", + "saved": "{name} a fost salvat în Fișiere.", + "saved_copy": "Salvat în Fișiere ca {name}.", + "save_failed": "{name} nu a putut fi salvat în Fișiere.", + "changed_on_server": "{name} a fost modificat în Fișiere după ce l-ați deschis.", + "gone_on_server": "{name} nu mai este în Fișiere.", + "keep_both": "Păstrează-le pe ambele", + "replace": "Înlocuiește", + "cancel": "Anulează", + "save_as_new": "Salvați ca fișier nou", + "import_to_mail": "Importați într-un dosar de e-mail…", + "import_to_mail_title": "Importați într-un dosar de e-mail", + "imported_to_mail": "Importat în {folder}.", + "import_to_mail_failed": "{name} nu a putut fi importat.", + "import_contacts": "Importați în contacte…", + "add_to_calendar": "Adăugați în calendar…" + }, "smime": { "your_certificates": "Certificatele dvs.", "your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..095db584b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Я открыл вам доступ к {name}", "share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Добавить все в календарь", + "zone_contacts_all": "Добавить все в контакты", + "save_to_files": "Сохранить в Файлы", + "saved": "{name} сохранён в Файлы.", + "saved_copy": "Сохранено в Файлы как {name}.", + "save_failed": "Не удалось сохранить {name} в Файлы.", + "changed_on_server": "{name} изменили в Файлах после того, как вы его открыли.", + "gone_on_server": "{name} больше нет в Файлах.", + "keep_both": "Оставить оба", + "replace": "Заменить", + "cancel": "Отмена", + "save_as_new": "Сохранить как новый файл", + "import_to_mail": "Импортировать в почтовую папку…", + "import_to_mail_title": "Импортировать в почтовую папку", + "imported_to_mail": "Импортировано в {folder}.", + "import_to_mail_failed": "Не удалось импортировать {name}.", + "import_contacts": "Импортировать в контакты…", + "add_to_calendar": "Добавить в календарь…" + }, "smime": { "your_certificates": "Ваши сертификаты", "your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..049561f05 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "Zdieľal som s vami {name}", "share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Pridať všetko do kalendára", + "zone_contacts_all": "Pridať všetko do kontaktov", + "save_to_files": "Uložiť do Súborov", + "saved": "{name} sa uložil do Súborov.", + "saved_copy": "Uložené do Súborov ako {name}.", + "save_failed": "{name} sa nepodarilo uložiť do Súborov.", + "changed_on_server": "{name} sa v Súboroch zmenil potom, ako ste ho otvorili.", + "gone_on_server": "{name} už v Súboroch nie je.", + "keep_both": "Ponechať oboje", + "replace": "Nahradiť", + "cancel": "Zrušiť", + "save_as_new": "Uložiť ako nový súbor", + "import_to_mail": "Importovať do priečinka pošty…", + "import_to_mail_title": "Importovať do priečinka pošty", + "imported_to_mail": "Importované do {folder}.", + "import_to_mail_failed": "{name} sa nepodarilo importovať.", + "import_contacts": "Importovať do kontaktov…", + "add_to_calendar": "Pridať do kalendára…" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..98bcac11b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2694,6 +2694,26 @@ "share_mail_subject": "ฉันแชร์ {name} กับคุณ", "share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "เพิ่มทั้งหมดในปฏิทิน", + "zone_contacts_all": "เพิ่มทั้งหมดในรายชื่อติดต่อ", + "save_to_files": "บันทึกลงในไฟล์", + "saved": "บันทึก {name} ลงในไฟล์แล้ว", + "saved_copy": "บันทึกลงในไฟล์เป็น {name} แล้ว", + "save_failed": "บันทึก {name} ลงในไฟล์ไม่ได้", + "changed_on_server": "{name} ถูกเปลี่ยนในไฟล์หลังจากที่คุณเปิด", + "gone_on_server": "{name} ไม่อยู่ในไฟล์แล้ว", + "keep_both": "เก็บทั้งสอง", + "replace": "แทนที่", + "cancel": "ยกเลิก", + "save_as_new": "บันทึกเป็นไฟล์ใหม่", + "import_to_mail": "นำเข้าไปยังโฟลเดอร์อีเมล…", + "import_to_mail_title": "นำเข้าไปยังโฟลเดอร์อีเมล", + "imported_to_mail": "นำเข้าไปยัง {folder} แล้ว", + "import_to_mail_failed": "นำเข้า {name} ไม่ได้", + "import_contacts": "นำเข้าไปยังรายชื่อติดต่อ…", + "add_to_calendar": "เพิ่มในปฏิทิน…" + }, "protocol_handlers": { "title": "แอปพลิเคชันเริ่มต้น", "description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..8459157e1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "{name} dosyasını seninle paylaştım", "share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Tümünü takvime ekle", + "zone_contacts_all": "Tümünü kişilere ekle", + "save_to_files": "Dosyalar'a kaydet", + "saved": "{name}, Dosyalar'a kaydedildi.", + "saved_copy": "Dosyalar'a {name} olarak kaydedildi.", + "save_failed": "{name}, Dosyalar'a kaydedilemedi.", + "changed_on_server": "{name}, siz açtıktan sonra Dosyalar'da değiştirildi.", + "gone_on_server": "{name} artık Dosyalar'da yok.", + "keep_both": "İkisini de tut", + "replace": "Değiştir", + "cancel": "İptal", + "save_as_new": "Yeni dosya olarak kaydet", + "import_to_mail": "Bir posta klasörüne aktar…", + "import_to_mail_title": "Bir posta klasörüne aktar", + "imported_to_mail": "{folder} klasörüne aktarıldı.", + "import_to_mail_failed": "{name} aktarılamadı.", + "import_contacts": "Kişilere aktar…", + "add_to_calendar": "Takvime ekle…" + }, "smime": { "your_certificates": "Sertifikalarınız", "your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..b7e981de5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "Я надав вам доступ до {name}", "share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Додати все до календаря", + "zone_contacts_all": "Додати все до контактів", + "save_to_files": "Зберегти у Файли", + "saved": "{name} збережено у Файли.", + "saved_copy": "Збережено у Файли як {name}.", + "save_failed": "Не вдалося зберегти {name} у Файли.", + "changed_on_server": "{name} змінили у Файлах після того, як ви його відкрили.", + "gone_on_server": "{name} більше немає у Файлах.", + "keep_both": "Залишити обидва", + "replace": "Замінити", + "cancel": "Скасувати", + "save_as_new": "Зберегти як новий файл", + "import_to_mail": "Імпортувати в поштову теку…", + "import_to_mail_title": "Імпортувати в поштову теку", + "imported_to_mail": "Імпортовано в {folder}.", + "import_to_mail_failed": "Не вдалося імпортувати {name}.", + "import_contacts": "Імпортувати в контакти…", + "add_to_calendar": "Додати до календаря…" + }, "smime": { "your_certificates": "Ваші сертифікати", "your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..a86a419df 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5792,6 +5792,26 @@ "share_mail_subject": "Tôi đã chia sẻ {name} với bạn", "share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "Thêm tất cả vào lịch", + "zone_contacts_all": "Thêm tất cả vào danh bạ", + "save_to_files": "Lưu vào Tệp", + "saved": "Đã lưu {name} vào Tệp.", + "saved_copy": "Đã lưu vào Tệp với tên {name}.", + "save_failed": "Không thể lưu {name} vào Tệp.", + "changed_on_server": "{name} đã bị thay đổi trong Tệp sau khi bạn mở.", + "gone_on_server": "{name} không còn trong Tệp.", + "keep_both": "Giữ cả hai", + "replace": "Thay thế", + "cancel": "Hủy", + "save_as_new": "Lưu thành tệp mới", + "import_to_mail": "Nhập vào thư mục thư…", + "import_to_mail_title": "Nhập vào thư mục thư", + "imported_to_mail": "Đã nhập vào {folder}.", + "import_to_mail_failed": "Không thể nhập {name}.", + "import_contacts": "Nhập vào danh bạ…", + "add_to_calendar": "Thêm vào lịch…" + }, "smime": { "your_certificates": "Chứng chỉ của bạn", "your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..7d6be81d3 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5780,6 +5780,26 @@ "share_mail_subject": "我與你共用了 {name}", "share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "全部加入行事曆", + "zone_contacts_all": "全部加入聯絡人", + "save_to_files": "儲存到檔案", + "saved": "已將 {name} 儲存到檔案。", + "saved_copy": "已以 {name} 儲存到檔案。", + "save_failed": "無法將 {name} 儲存到檔案。", + "changed_on_server": "你開啟 {name} 後,它在檔案中被變更了。", + "gone_on_server": "{name} 已不在檔案中。", + "keep_both": "保留兩者", + "replace": "取代", + "cancel": "取消", + "save_as_new": "另存為新檔案", + "import_to_mail": "匯入到郵件資料夾…", + "import_to_mail_title": "匯入到郵件資料夾", + "imported_to_mail": "已匯入到 {folder}。", + "import_to_mail_failed": "無法匯入 {name}。", + "import_contacts": "匯入到聯絡人…", + "add_to_calendar": "加入行事曆…" + }, "smime": { "your_certificates": "您的憑證", "your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..673125407 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5518,6 +5518,26 @@ "share_mail_subject": "我与你共享了 {name}", "share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}" }, + "ux_p2_pim_files": { + "zone_calendar_all": "全部添加到日历", + "zone_contacts_all": "全部添加到联系人", + "save_to_files": "保存到文件", + "saved": "已将 {name} 保存到文件。", + "saved_copy": "已作为 {name} 保存到文件。", + "save_failed": "无法将 {name} 保存到文件。", + "changed_on_server": "你打开 {name} 后,它在文件中被更改了。", + "gone_on_server": "{name} 已不在文件中。", + "keep_both": "保留两者", + "replace": "替换", + "cancel": "取消", + "save_as_new": "另存为新文件", + "import_to_mail": "导入到邮件文件夹…", + "import_to_mail_title": "导入到邮件文件夹", + "imported_to_mail": "已导入到 {folder}。", + "import_to_mail_failed": "无法导入 {name}。", + "import_contacts": "导入到联系人…", + "add_to_calendar": "添加到日历…" + }, "smime": { "your_certificates": "您的证书", "your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密", diff --git a/stores/__tests__/file-store-upload-into.test.ts b/stores/__tests__/file-store-upload-into.test.ts new file mode 100644 index 000000000..931065452 --- /dev/null +++ b/stores/__tests__/file-store-upload-into.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; +import type { UploadConflict } from '@/lib/files-upload-conflicts'; + +// pim-p2-6: files dropped on a folder's row go into that folder, and a name +// taken there (not in the folder in view) asks. + +function makeClient(initial: FileNode[]) { + const nodes: FileNode[] = initial.map(n => ({ ...n })); + let seq = 0; + const client = { + getCapabilities: () => ({}), + getFilesAccountId: () => 'acct', + async listAllFileNodes() { return nodes.map(n => ({ ...n })); }, + async listFileNodes(parentId: string | null) { return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n })); }, + async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + replaceFileNodeBlob: vi.fn(async () => {}), + async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; }, + _nodes: () => nodes, + }; + return client as unknown as IJMAPClient & { _nodes: () => FileNode[] }; +} + +const folder = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '', +}); +const file = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'text/plain', blobId: `old-${id}`, size: 3, created: '', modified: '', +}); + +beforeEach(() => { + useFileStore.setState({ + client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], selectedResources: new Set(), clipboard: null, lastAction: null, + }); +}); + +describe('uploading into a folder of the one in view (pim-p2-6)', () => { + it('creates the files in that folder and asks about names taken there', async () => { + const client = makeClient([folder('d', 'Documents', null), file('r', 'report.txt', 'd'), file('t', 'top.txt', null)]); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + const asked: UploadConflict[][] = []; + const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => { + asked.push(conflicts); + return new Map(conflicts.map(c => [c.name, 'keep' as const])); + }); + await useFileStore.getState().uploadFiles( + [new File(['x'], 'report.txt'), new File(['y'], 'top.txt')], + { resolveConflicts, into: { id: 'd', name: 'Documents' } }, + ); + // report.txt is taken in Documents, top.txt only in the folder in view. + expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]); + const added = client._nodes().filter(n => n.id.startsWith('n')); + expect(added.map(n => [n.name, n.parentId]).sort()).toEqual([['report (1).txt', 'd'], ['top.txt', 'd']]); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 349dbe939..987a8239e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -69,6 +69,19 @@ export interface UploadReport { export interface UploadOptions { /** Asked when a name is taken in the target folder; without it, new ones are numbered. */ resolveConflicts?: UploadConflictResolver; + /** A folder of the one in view, as when files are dropped on its row. Default: the folder in view. */ + into?: { id: string; name: string }; +} + +/** Where an upload goes: the folder in view, or one of its folders (`into`), with what that folder holds. */ +async function uploadTarget( + state: Pick, + into: UploadOptions['into'], +): Promise<{ parentId: string | null; path: string; existing: FileResource[] }> { + if (!into || into.id === state.currentParentId) { + return { parentId: state.currentParentId, path: state.currentPath, existing: state.resources }; + } + return { parentId: into.id, path: joinPath(state.currentPath, into.name), existing: await state.listByParentId(into.id) }; } interface ClipboardState { @@ -1120,10 +1133,11 @@ export const useFileStore = create((set, get) => ({ }, uploadFiles: async (allFiles: File[], options?: UploadOptions) => { - const { client, currentParentId, currentPath, resources } = get(); + const { client } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client) return report; - assertCanAddTo(currentParentId); + assertCanAddTo(options?.into?.id ?? get().currentParentId); + const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into); // Plugins may hold back single files; the rest go up. const files: File[] = []; @@ -1204,10 +1218,11 @@ export const useFileStore = create((set, get) => ({ }, uploadFolder: async (allFiles: File[], options?: UploadOptions) => { - const { client, currentParentId, currentPath, resources } = get(); + const { client } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client || allFiles.length === 0) return report; - assertCanAddTo(currentParentId); + assertCanAddTo(options?.into?.id ?? get().currentParentId); + const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into); const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const dirOf = (relativePath: string) => { From 81f8bf6d603b167214742cf1ff2deefefa0eb9ff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:56:15 +0100 Subject: [PATCH 3/6] feat: save a document edited in its program back into Files, and ask Keep both or Replace when it changed there meanwhile --- .../edited-files-host-save-to-files.test.tsx | 154 ++++++++++++++++++ components/desktop/edited-files-host.tsx | 64 +++++++- lib/desktop/edit-attachments.ts | 16 +- lib/desktop/save-to-files.ts | 61 +++++++ lib/jmap/client.ts | 15 ++ 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 components/desktop/__tests__/edited-files-host-save-to-files.test.tsx create mode 100644 lib/desktop/save-to-files.ts diff --git a/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx b/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx new file mode 100644 index 000000000..a40406995 --- /dev/null +++ b/components/desktop/__tests__/edited-files-host-save-to-files.test.tsx @@ -0,0 +1,154 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// pim-p2-1: a document opened from Files in its program goes back into Files +// when the program saves, first choice on the card. If the file changed on +// the server meanwhile, the card asks Keep both or Replace. + +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +const files = vi.hoisted(() => ({ client: null as unknown, refresh: vi.fn() })); + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('@/lib/device-policy', () => ({ canDownload: () => true, canExport: () => true })); +vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true })); +vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink: vi.fn() })); +vi.mock('@/lib/desktop/mail-notifications', () => ({ contentHiddenFor: () => false })); +vi.mock('@/lib/launch-handoff', () => ({ handOffDraft: vi.fn() })); +vi.mock('@/lib/popout/open', () => ({ openDraftWindow: vi.fn() })); +vi.mock('@/lib/download-guard', () => ({ saveBlobAs: vi.fn(() => true) })); +vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } })); +vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } })); +vi.mock('@/lib/sign-out-cleanup', () => ({ addSignOutGate: () => () => {}, onBeforeSignOut: () => () => {}, onFullSignOut: () => {} })); +vi.mock('@/stores/account-store', () => { + const list = [{ id: 'acct', cookieSlot: 2 }]; + const useAccountStore = (select: (s: { accounts: typeof list }) => unknown) => select({ accounts: list }); + useAccountStore.getState = () => ({ accounts: list }); + return { useAccountStore }; +}); +vi.mock('@/stores/auth-store', () => { + const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: 'acct' }); + useAuthStore.getState = () => ({ activeAccountId: 'acct', client: null, getClientForAccount: () => undefined }); + return { useAuthStore }; +}); +vi.mock('@/stores/file-store', () => ({ + useFileStore: { getState: () => ({ client: files.client, currentAccountId: 'acct', refresh: files.refresh }) }, +})); + +import { EditedFilesHost } from '../edited-files-host'; +import { parseEditSource, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments'; + +const NODE_SOURCE = JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-old', parentId: 'p1' }); +const session = (over: Record = {}) => ({ + id: 's1', name: 'budget-2026.xlsx', type: 'x/y', account: 'acct', source: NODE_SOURCE, changed: true, version: 2, hash: 'h2', claimed: false, held: false, savedAs: false, lastChance: false, ...over, +}); + +let edit: Record>; +let client: { + getFileNodes: ReturnType; + uploadBlob: ReturnType; + replaceFileNodeBlob: ReturnType; + createFileNode: ReturnType; +}; + +function show() { + const sessions = [session()]; + edit.list.mockResolvedValue(sessions); + useEditSessions.setState({ sessions }); + return render(); +} + +beforeEach(() => { + vi.clearAllMocks(); + client = { + getFileNodes: vi.fn(async () => [{ id: 'n1', blobId: 'b-old', name: 'budget-2026.xlsx', parentId: 'p1' }]), + uploadBlob: vi.fn(async () => ({ blobId: 'b-new', size: 1, type: 'x/y' })), + replaceFileNodeBlob: vi.fn(async () => {}), + createFileNode: vi.fn(async () => ({ id: 'n2', name: 'budget-2026 (2).xlsx', parentId: 'p1' })), + }; + files.client = client; + edit = { + canEdit: vi.fn(() => true), + list: vi.fn(async () => []), + read: vi.fn(async () => ({ ok: true, name: 'budget-2026.xlsx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })), + taken: vi.fn(async () => {}), + setSource: vi.fn(), + discard: vi.fn(async () => ({ held: [] })), + forceDiscard: vi.fn(async () => ({ held: [] })), + askClear: vi.fn(async () => true), + shown: vi.fn(), + onChange: vi.fn(() => () => {}), + }; + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit, os: { notify: vi.fn(async () => ({})) } }; + resetEditSessions(); +}); + +afterEach(() => { + cleanup(); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('a file opened from Files remembers its node', () => { + it('reads the node source back', () => { + expect(parseEditSource(JSON.stringify({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null }))) + .toEqual({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null }); + // Older sessions keep working. + expect(parseEditSource('{"kind":"file"}')).toEqual({ kind: 'file' }); + expect(parseEditSource(JSON.stringify({ kind: 'node' }))).toEqual({ kind: 'file' }); + }); +}); + +describe('Save to Files (pim-p2-1)', () => { + it('is the first choice on the card of a file from Files', () => { + show(); + const buttons = screen.getAllByRole('button').filter((b) => b.textContent); + expect(buttons[0].textContent).toBe('save_to_files'); + expect(screen.getByText('new_message')).toBeTruthy(); + expect(screen.getByText('save_copy')).toBeTruthy(); + }); + + it('puts the new content into the same node when nobody changed it', async () => { + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new')); + expect(client.createFileNode).not.toHaveBeenCalled(); + expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-new', parentId: 'p1' })); + expect(edit.taken).toHaveBeenCalledWith('s1', 'h2'); + expect(toast.success).toHaveBeenCalledWith('saved'); + expect(files.refresh).toHaveBeenCalled(); + }); + + it('asks Keep both or Replace when the file changed on the server, and Keep both adds a copy beside it', async () => { + client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('changed_on_server')); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + expect(edit.taken).not.toHaveBeenCalled(); + expect(screen.getByText('replace')).toBeTruthy(); + fireEvent.click(screen.getByText('keep_both')); + await waitFor(() => expect(client.createFileNode).toHaveBeenCalledWith('budget-2026.xlsx', 'b-new', 'x/y', 2, 'p1')); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n2', blobId: 'b-new', parentId: 'p1' })); + expect(edit.taken).toHaveBeenCalledWith('s1', 'h2'); + expect(toast.success).toHaveBeenCalledWith('saved_copy'); + }); + + it('Replace writes over the change', async () => { + client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + fireEvent.click(await screen.findByText('replace')); + await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new')); + }); + + it('offers a new file when the original is gone', async () => { + client.getFileNodes.mockResolvedValue([]); + show(); + fireEvent.click(screen.getByText('save_to_files')); + await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('gone_on_server')); + expect(screen.queryByText('replace')).toBeNull(); + expect(screen.getByText('save_as_new')).toBeTruthy(); + }); +}); diff --git a/components/desktop/edited-files-host.tsx b/components/desktop/edited-files-host.tsx index 5eaf7906f..0ff889da3 100644 --- a/components/desktop/edited-files-host.tsx +++ b/components/desktop/edited-files-host.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/navigation"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments"; +import { filesClientFor, refreshFilesFor, saveBackToNode, saveBesideNode, type NodeEditSource } from "@/lib/desktop/save-to-files"; import { openMailLink } from "@/lib/desktop/mail-links"; import { contentHiddenFor } from "@/lib/desktop/mail-notifications"; import type { DesktopEditSession } from "@/lib/desktop/types"; @@ -35,7 +36,10 @@ function useSignedInAccounts(): Set { */ export function EditedFilesHost() { const t = useTranslations("desktopEdit"); + const tFiles = useTranslations("ux_p2_pim_files"); const router = useRouter(); + // A file from Files that changed on the server, or went, since it was opened: what to do is asked on its card. + const [conflicts, setConflicts] = useState>({}); const tRef = useRef(t); tRef.current = t; const sessions = useEditSessions((s) => s.sessions); @@ -148,6 +152,42 @@ export function EditedFilesHost() { if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash); }); + /** + * Save to Files (pim-p2-1): back into the node it came from, unless that changed on the + * server meanwhile; then the card asks Keep both or Replace. `how`: "check" first, + * "replace" over a change, "beside" as a new file next to it. + */ + const saveToFiles = (session: DesktopEditSession, source: NodeEditSource, how: "check" | "replace" | "beside") => withFile(session, async (file, hash) => { + const client = filesClientFor(session.account); + const bridge = getEditBridge(); + if (!client || !bridge) { + toast.error(tFiles("save_failed", { name: session.name })); + return; + } + try { + if (how === "beside") { + const saved = await saveBesideNode(client, source, file); + bridge.setSource(session.id, JSON.stringify(saved.source)); + await bridge.taken(session.id, hash); + toast.success(tFiles("saved_copy", { name: saved.name })); + } else { + const result = await saveBackToNode(client, source, file, how === "replace"); + if (result.status !== "saved") { + setConflicts((all) => ({ ...all, [session.id]: result.status })); + return; + } + bridge.setSource(session.id, JSON.stringify({ ...source, blobId: result.blobId })); + await bridge.taken(session.id, hash); + toast.success(tFiles("saved", { name: session.name })); + } + setConflicts(({ [session.id]: _done, ...rest }) => rest); + refreshFilesFor(client); + } catch (error) { + console.error("Could not save an edited file to Files:", error); + toast.error(tFiles("save_failed", { name: session.name })); + } + }); + const accountLabel = (session: DesktopEditSession) => { if (accountList.length < 2) return null; const entry = accountList.find((a) => a.id === session.account); @@ -172,6 +212,8 @@ export function EditedFilesHost() { const source = parseEditSource(session.source); const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null; const account = accountLabel(session); + const node = source.kind === "node" ? source : null; + const conflict = node ? conflicts[session.id] : undefined; return (
@@ -181,6 +223,11 @@ export function EditedFilesHost() { {account &&

{account}

}

{t("changed_body")}

{session.lastChance &&

{t("last_chance")}

} + {conflict && ( +

+ {conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })} +

+ )}
+ {node && conflict ? ( +
+ + {conflict === "changed" && ( + + )} + +
+ ) : (
+ {node && ( + + )} {source.kind === "mail" && ( )} @@ -200,10 +261,11 @@ export function EditedFilesHost() { )} {source.kind !== "mail" && !draftId && ( - + )}
+ )} ); })} diff --git a/lib/desktop/edit-attachments.ts b/lib/desktop/edit-attachments.ts index 0640a108b..c515fadef 100644 --- a/lib/desktop/edit-attachments.ts +++ b/lib/desktop/edit-attachments.ts @@ -27,13 +27,27 @@ export type EditSource = | { kind: 'mail'; emailId: string; ownerAccountId?: string } /** A draft's attachment: offers the update, in the composer holding it. */ | { kind: 'draft'; draftId: string | null; ownerAccountId?: string } + /** + * A file in Files, opened with "Open in default app": a save goes back into this node. + * `blobId` is the content last known to be there, to tell whether someone changed it + * on the server meanwhile. + */ + | { kind: 'node'; nodeId: string; blobId: string | null; parentId: string | null } /** Its message went or it was taken off a draft: only the file is left. */ | { kind: 'file' }; export function parseEditSource(raw: string): EditSource { try { - const parsed = JSON.parse(raw) as Partial & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown }; + const parsed = JSON.parse(raw) as Partial & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown; nodeId?: unknown; blobId?: unknown; parentId?: unknown }; const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined; + if (parsed.kind === 'node' && typeof parsed.nodeId === 'string' && parsed.nodeId) { + return { + kind: 'node', + nodeId: parsed.nodeId, + blobId: typeof parsed.blobId === 'string' ? parsed.blobId : null, + parentId: typeof parsed.parentId === 'string' ? parsed.parentId : null, + }; + } if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId }; if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId }; } catch { diff --git a/lib/desktop/save-to-files.ts b/lib/desktop/save-to-files.ts new file mode 100644 index 000000000..f1f77b98e --- /dev/null +++ b/lib/desktop/save-to-files.ts @@ -0,0 +1,61 @@ +/** + * "Save to Files" for a document opened from Files in its own program (pim-p2-1): the saved + * bytes go back into the node they came from, as an upload's Replace does (same id, so shares + * and links stay), the way OneDrive or Nextcloud write back an edited file. + * + * Before that the node is read again: when its content is no longer what was opened (someone + * changed it on the server, or it was deleted), nothing is overwritten and the caller asks + * Keep both (a numbered copy beside it) or Replace. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useFileStore } from '@/stores/file-store'; +import type { EditSource } from './edit-attachments'; + +export type NodeEditSource = Extract; + +export type SaveBackResult = + | { status: 'saved'; blobId: string } + /** Someone changed the file on the server since it was opened. */ + | { status: 'changed' } + /** It isn't there any more. */ + | { status: 'gone' }; + +/** The client for the account the file belongs to, whichever account is on screen. */ +export function filesClientFor(account: string): IJMAPClient | null { + const files = useFileStore.getState(); + if (files.client && files.currentAccountId === account) return files.client; + const auth = useAuthStore.getState(); + return auth.getClientForAccount?.(account) ?? (auth.activeAccountId === account ? auth.client : null) ?? null; +} + +/** Puts the file into its node. `force`: also when it changed on the server (Replace). */ +export async function saveBackToNode(client: IJMAPClient, source: NodeEditSource, file: File, force = false): Promise { + const [node] = await client.getFileNodes([source.nodeId], ['id', 'blobId', 'name', 'parentId']); + if (!node) return { status: 'gone' }; + if (!force && source.blobId && node.blobId !== source.blobId) return { status: 'changed' }; + const { blobId } = await client.uploadBlob(file); + await client.replaceFileNodeBlob(source.nodeId, blobId); + return { status: 'saved', blobId }; +} + +/** Keep both: a new file beside the original, numbered by the server when the name is taken. */ +export async function saveBesideNode(client: IJMAPClient, source: NodeEditSource, file: File): Promise<{ source: NodeEditSource; name: string }> { + const { blobId, type } = await client.uploadBlob(file); + let created; + try { + created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, source.parentId); + } catch (error) { + // The folder went too: the top of Files. + if (source.parentId === null) throw error; + created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, null); + } + return { source: { kind: 'node', nodeId: created.id, blobId, parentId: created.parentId ?? null }, name: created.name }; +} + +/** Files on screen shows the new size and date. */ +export function refreshFilesFor(client: IJMAPClient): void { + const files = useFileStore.getState(); + if (files.client === client) void files.refresh().catch(() => {}); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6b445018b..ed218edcb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient { } async getFileNodes(ids: string[] | null, properties?: string[]): Promise { + // A node of a shared drive is known as "accountId:nodeId" (namespaceFileNode): + // read it in its owner's account and hand it back under the same name. + const shared = ids && ids.length > 0 ? this.resolveFileNodeId(ids[0]) : null; + if (shared && shared.accountId !== this.getFilesAccountId()) { + const rawIds = ids!.map((id) => this.resolveFileNodeId(id)).filter((node) => node.accountId === shared.accountId).map((node) => node.id as string); + const response = await this.request( + [["FileNode/get", { accountId: shared.accountId, ids: rawIds, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }, "fn0"]], + this.fileUsing(), + ); + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + throw new Error(result?.[1]?.description || "FileNode/get failed"); + } + return ((result[1].list || []) as FileNode[]).map((node) => this.namespaceFileNode(fromWireFileNode(node), shared.accountId)); + } const accountId = this.getFilesAccountId(); const args: Record = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }; From 546b76b47e0f4b58adaf8523ea0f7a012eca19a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:56:15 +0100 Subject: [PATCH 4/6] feat: open saved messages from Files, offer their imports there, take drops on a folder row, and step through files without a preview --- .../__tests__/ux-p2-pim-file-panes.test.tsx | 61 ++++++++++ .../ux-p2-pim-files-browser.test.tsx | 52 ++++++++ components/files/file-browser.tsx | 49 +++++++- components/files/file-no-preview.tsx | 19 ++- components/files/file-preview-modal.tsx | 28 ++++- components/files/files-app.tsx | 113 +++++++++++++++--- components/files/import-eml-dialog.tsx | 60 ++++++++++ lib/files-app-actions.ts | 22 ++++ 8 files changed, 369 insertions(+), 35 deletions(-) create mode 100644 components/files/__tests__/ux-p2-pim-file-panes.test.tsx create mode 100644 components/files/import-eml-dialog.tsx create mode 100644 lib/files-app-actions.ts diff --git a/components/files/__tests__/ux-p2-pim-file-panes.test.tsx b/components/files/__tests__/ux-p2-pim-file-panes.test.tsx new file mode 100644 index 000000000..fb84ea156 --- /dev/null +++ b/components/files/__tests__/ux-p2-pim-file-panes.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FileNoPreview } from '../file-no-preview'; +import { FilePreviewModal } from '../file-preview-modal'; +import { fileAppAction } from '@/lib/files-app-actions'; +import type { FileResource } from '@/stores/file-store'; + +// pim-p2-18: the pane of a file with no preview has the preview's header with +// Previous/Next and one Download. pim-p2-7: the app's own action for a file. + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const budget = { + id: 'x', name: 'budget.xlsx', serverName: 'budget.xlsx', isDirectory: false, contentType: 'application/vnd.ms-excel', + contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b-x', parentId: null, +} as FileResource; + +afterEach(() => cleanup()); + +describe('the pane of a file with no preview (pim-p2-18)', () => { + it('shows Download once and goes to the next file', () => { + const onGo = vi.fn(); + render(); + expect(screen.getAllByRole('button', { name: /download/ })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'next_file' })); + expect(onGo).toHaveBeenCalledWith('c.bin'); + }); + + it('has the app action first', () => { + const onRun = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('file-app-action')); + expect(onRun).toHaveBeenCalled(); + }); +}); + +describe('the preview offers the app action (pim-p2-7)', () => { + it('as a labelled button in the header', () => { + const onClick = vi.fn(); + render( + new Promise(() => {})} + extraAction={{ label: 'import_contacts', onClick }} + />, + ); + fireEvent.click(screen.getByRole('button', { name: 'import_contacts' })); + expect(onClick).toHaveBeenCalled(); + }); + + it('knows which files have one', () => { + expect(fileAppAction('forwarded.eml')).toBe('import-mail'); + expect(fileAppAction('contacts-export.csv')).toBe('import-contacts'); + expect(fileAppAction('team.vcf')).toBe('import-contacts'); + expect(fileAppAction('trip.ics')).toBe('add-calendar'); + expect(fileAppAction('budget.xlsx')).toBeNull(); + }); +}); diff --git a/components/files/__tests__/ux-p2-pim-files-browser.test.tsx b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx index 7659f8847..69b308a90 100644 --- a/components/files/__tests__/ux-p2-pim-files-browser.test.tsx +++ b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx @@ -113,3 +113,55 @@ describe('the ⋮ of a row opens its menu beside the button, never over it (pim- expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy(); }); }); + +const filesTransfer = (files: File[]) => ({ + types: ['Files'], + files, + items: [], + dropEffect: 'none', + effectAllowed: 'all', + getData: () => '', + setData: () => {}, +}); + +describe('files from outside dropped on Files (pim-p2-6)', () => { + it('go into the folder whose row they land on', async () => { + const h = renderBrowser(); + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + const row = document.querySelector('[data-resource="Documents"]')!; + fireEvent.dragOver(row, { dataTransfer: filesTransfer([file]) }); + fireEvent.drop(row, { dataTransfer: filesTransfer([file]) }); + await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1)); + expect(h.onUploadFiles).toHaveBeenCalledWith([file], expect.objectContaining({ id: 'd', name: 'Documents' })); + }); + + it('go into the folder in view when they land on a file or the list', async () => { + const h = renderBrowser(); + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + const row = document.querySelector('[data-resource="notes.txt"]')!; + fireEvent.drop(row, { dataTransfer: filesTransfer([file]) }); + await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1)); + expect((h.onUploadFiles.mock.calls[0] as unknown[])[1]).toBeUndefined(); + }); +}); + +describe('files the app knows (pim-p2-7)', () => { + it('opens a saved message in the preview, as a mail', () => { + const eml = res('m', 'forwarded.eml', { contentType: 'message/rfc822' }); + const h = renderBrowser({}, [docs, notes, eml]); + fireEvent.doubleClick(screen.getByTitle('forwarded.eml')); + expect(h.onPreviewFile).toHaveBeenCalledWith('forwarded.eml'); + expect(h.onShowFileInfo).not.toHaveBeenCalled(); + }); + + it('puts the app action in the item menu', () => { + const csv = res('c', 'contacts-export.csv', { contentType: 'text/csv' }); + const onRun = vi.fn(); + renderBrowser({ appActionFor: (r) => (r.name.endsWith('.csv') ? { kind: 'import-contacts', label: 'import_contacts', onRun } : null) }, [docs, notes, csv]); + fireEvent.contextMenu(screen.getByTitle('contacts-export.csv')); + fireEvent.click(screen.getByRole('menuitem', { name: 'import_contacts' })); + expect(onRun).toHaveBeenCalledTimes(1); + fireEvent.contextMenu(screen.getByTitle('notes.txt')); + expect(screen.queryByRole('menuitem', { name: 'import_contacts' })).toBeNull(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 6ce0ce0d2..07467e075 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff, FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen, - RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, + RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, Inbox, Calendar, UserPlus, } from "@/components/icons"; import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -33,6 +33,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { AppMain } from "@/components/layout/app-landmarks"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; +import { getFilePreviewKind } from "@/lib/file-preview"; +import type { FileAppAction } from "@/lib/files-app-actions"; import { useAppSidePanel } from "@/components/plugins/app-side-panel"; import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types"; import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store"; @@ -74,8 +76,11 @@ interface FileBrowserProps { clipboard: ClipboardState | null; onNavigate: (path: string, resourceId?: string | null) => void; onCreateFolder: (name: string) => Promise; - onUploadFiles: (files: File[]) => Promise; - onUploadFolder: (files: File[]) => Promise; + /** What the app itself does with a file: "Import into contacts…" and the like (pim-p2-7). */ + appActionFor?: (resource: FileResource) => { kind: FileAppAction; label: string; onRun: () => void } | null; + /** `into`: a folder of the one in view, when files are dropped on its row. */ + onUploadFiles: (files: File[], into?: FileResource) => Promise; + onUploadFolder: (files: File[], into?: FileResource) => Promise; onCancelUpload: () => void; /** Delete: into the trash, or for good when in the trash or a shared drive. */ onDelete: (name: string) => Promise; @@ -149,7 +154,8 @@ interface FileBrowserProps { } function isPreviewable(name: string): boolean { - return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); + // A saved message opens like a mail (pim-p2-7). + return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name) || getFilePreviewKind(name) === "eml"; } type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | { kind: "new" }; @@ -231,6 +237,7 @@ export function FileBrowser({ onShowFileInfo, canOpenInApp, onOpenInApp, + appActionFor, onShared, }: FileBrowserProps) { const t = useTranslations("files"); @@ -615,6 +622,16 @@ export function FileBrowser({ void onMoveTo(names, targetId); }, [onMoveTo]); + // Files from outside dropped on a folder's row go into that folder (pim-p2-6). + const uploadIntoRef = useRef<(files: File[], asFolder: boolean, into: FileResource) => Promise>(async () => {}); + uploadIntoRef.current = async (files, asFolder, into) => { + if (files.length === 0) return; + if (asFolder) await onUploadFolder(files, into); + else await onUploadFiles(files, into); + }; + const takesFilesFromOutside = useCallback((e: React.DragEvent, resource: FileResource) => + resource.isDirectory && !resource.isSymlink && canCreate && !isInternalDrag(e) && Array.from(e.dataTransfer.types ?? []).includes("Files"), [canCreate]); + const itemDragProps = useCallback((resource: FileResource) => ({ draggable: !touch && !inTrash, onDragStart: (e: React.DragEvent) => { @@ -623,6 +640,12 @@ export function FileBrowser({ e.dataTransfer.effectAllowed = "move"; }, onDragOver: (e: React.DragEvent) => { + if (takesFilesFromOutside(e, resource)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDropTarget(resource.name); + return; + } if (!resource.isDirectory || !isInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); @@ -631,6 +654,16 @@ export function FileBrowser({ }, onDragLeave: () => setDropTarget((cur) => (cur === resource.name ? null : cur)), onDrop: (e: React.DragEvent) => { + if (takesFilesFromOutside(e, resource)) { + e.preventDefault(); + e.stopPropagation(); + setDropTarget(null); + dragDepth.current = 0; + setIsDraggingFiles(false); + const transfer = e.dataTransfer; + void getDroppedFilesAndFolders(transfer).then(({ files, hasDirectories }) => uploadIntoRef.current(files, hasDirectories, resource)); + return; + } if (!resource.isDirectory || !isInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); @@ -638,7 +671,7 @@ export function FileBrowser({ const names = readDraggedNames(e).filter((n) => n !== resource.name); if (names.length > 0) moveNames(names, resource.id); }, - }), [touch, inTrash, selectedResources, moveNames]); + }), [touch, inTrash, selectedResources, moveNames, takesFilesFromOutside]); const runUpload = useCallback(async (files: File[], asFolder: boolean) => { if (files.length === 0) return; @@ -974,6 +1007,12 @@ export function FileBrowser({ {canOpenInApp?.(resource) && onOpenInApp && ( { closeMenu(); onOpenInApp(resource); }} /> )} + {(() => { + const action = appActionFor?.(resource); + if (!action) return null; + const icon = action.kind === "import-mail" ? Inbox : action.kind === "add-calendar" ? Calendar : UserPlus; + return { closeMenu(); action.onRun(); }} />; + })()} )} {canKeepOffline(resource) && ( diff --git a/components/files/file-no-preview.tsx b/components/files/file-no-preview.tsx index 3b8f78b6f..1859a85ac 100644 --- a/components/files/file-no-preview.tsx +++ b/components/files/file-no-preview.tsx @@ -14,22 +14,26 @@ const ICON_OPTIONS = { showIcons: true, coloredIcons: true }; * A file the app can't show and no office editor opens (a spreadsheet with * no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with * Download and, in the desktop app, Open in the program the computer uses - * for it (pim-16). It used to download at once, with no word. + * for it (pim-16). It used to download at once, with no word. The header is + * the preview's, with the folder's previous and next file; Download is in + * the body only, once (pim-p2-18). */ -export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: { +export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, appAction, neighbours }: { resource: FileResource; onClose: () => void; /** Left out when a device policy turned downloads off. */ onDownload?: () => void; /** Only in the desktop app, for a file it may open. */ onOpenInApp?: () => void; + /** What the app itself does with this kind of file: "Import into contacts…" (pim-p2-7). */ + appAction?: { label: string; onRun: () => void }; neighbours?: PreviewNeighbours; }) { const t = useTranslations("files"); const tUx = useTranslations("ux_pim_files"); useCloseOnBack({ onBack: onClose }); return ( - +

{tUx("no_preview")}

+ {appAction && ( + + )} {onOpenInApp && ( - )} {onDownload && ( - diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 59f681f03..ece6eaf29 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -9,6 +9,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; @@ -76,6 +77,12 @@ interface FilePreviewModalProps { * accounts signed in here. */ policyAccount?: string | { sourceClientAccountId?: string | null } | null; + /** + * What the app itself does with this kind of file, as a labelled button in + * the header: "Import to a mail folder…" for a saved message, "Import into + * contacts…" for a .csv (pim-p2-7). + */ + extraAction?: { label: string; onClick: () => void }; } function SimpleMarkdown({ content }: { content: string }) { @@ -169,7 +176,7 @@ function processInline(text: string): React.ReactNode { return parts.length === 1 ? parts[0] : <>{parts}; } -export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount }: FilePreviewModalProps) { +export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount, extraAction }: FilePreviewModalProps) { const t = useTranslations("files"); // No downloads: no download button, no new tab, no save from the // browser's own PDF viewer or media controls. No printing: the PDF goes @@ -375,11 +382,20 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi neighbours={neighbours} // An installed app hands the new window to the system browser, which // cannot load our blob: URL (#865): no dead button there. - actions={objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) ? ( - window.open(objectUrl, "_blank", "noopener,noreferrer")}> - - - ) : undefined} + actions={( + <> + {extraAction && ( + + )} + {objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) && ( + window.open(objectUrl, "_blank", "noopener,noreferrer")}> + + + )} + + )} >
{loading && } diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 344d33605..048071738 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -9,7 +9,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/ import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store"; -import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; +import { filesDropTarget, hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; +import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; +import { isInternalDrag } from "@/components/files/file-item-parts"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; @@ -37,6 +39,10 @@ import { savePendingMailto } from "@/lib/protocol-handlers/session"; import { formatRecipient } from "@/lib/email-composer-utils"; import { useRouter } from "@/i18n/navigation"; import { useEditAttachment } from "@/hooks/use-edit-attachment"; +import { fileAppAction, type FileAppAction } from "@/lib/files-app-actions"; +import { handOffFiles } from "@/lib/launch-handoff"; +import { ImportEmlDialog } from "@/components/files/import-eml-dialog"; +import { useCalendarStore } from "@/stores/calendar-store"; import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts"; import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status"; import { apiFetch } from "@/lib/browser-navigation"; @@ -409,7 +415,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { }), ), []); - const handleUploadFiles = useCallback(async (files: File[]) => { + const handleUploadFiles = useCallback(async (files: File[], into?: FileResource) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); files = files.filter(f => f.size <= maxSizeUpload); @@ -419,14 +425,14 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFiles(files, { resolveConflicts })); + showUploadReport(await uploadFiles(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) })); } catch (err) { console.error("Failed to upload files:", err); toast.error(t("upload_error"), failureReason(err)); } }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); - const handleUploadFolder = useCallback(async (files: File[]) => { + const handleUploadFolder = useCallback(async (files: File[], into?: FileResource) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); files = files.filter(f => f.size <= maxSizeUpload); @@ -436,7 +442,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFolder(files, { resolveConflicts })); + showUploadReport(await uploadFolder(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) })); } catch (err) { console.error("Failed to upload folder:", err); toast.error(t("upload_error"), failureReason(err)); @@ -693,11 +699,56 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { name: r.name, type: r.contentType || "application/octet-stream", account: filesLocalAccount, - source: { kind: "file" }, + // Saved in its program, it goes back into this node (pim-p2-1). + source: { kind: "node", nodeId: r.id, blobId: r.blobId ?? null, parentId: r.parentId ?? null }, load: async () => (await getFileContent(r.name)).blob, }); + // The program has it now: the pane has nothing more to offer (pim-p2-18). + setInfoFile((open) => (open === r.name ? null : open)); }, [openInDesktopApp, filesLocalAccount, getFileContent]); + // What the app itself does with a file kept here (pim-p2-7). + const tFiles2 = useTranslations("ux_p2_pim_files"); + const calendarSupported = useCalendarStore((s) => s.supportsCalendar); + const calendarAllowed = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled")); + const contactsAllowed = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); + const calendarOn = calendarSupported && calendarAllowed; + const contactsOn = !!storeClient?.supportsContacts?.() && contactsAllowed; + const [importEml, setImportEml] = useState(null); + const appActionOf = useCallback((r: FileResource): FileAppAction | null => { + if (r.isDirectory || r.isSymlink || r.trash) return null; + const action = fileAppAction(r.name, r.contentType); + if (action === "add-calendar" && !calendarOn) return null; + if (action === "import-contacts" && !contactsOn) return null; + return action; + }, [calendarOn, contactsOn]); + const runAppAction = useCallback(async (r: FileResource) => { + const action = appActionOf(r); + if (!action) return; + let file: File; + try { + const { blob, contentType } = await getFileContent(r.name); + file = new File([blob], r.name, { type: contentType || r.contentType || blob.type }); + } catch (err) { + console.error("Could not read a file for its import:", err); + toast.error(t("download_error")); + return; + } + if (action === "import-mail") { + setImportEml(file); + return; + } + setPreviewFile(null); + setInfoFile(null); + handOffFiles([file], action === "add-calendar" ? "calendar" : "contacts"); + }, [appActionOf, getFileContent, t]); + const appActionFor = useCallback((r: FileResource) => { + const action = appActionOf(r); + if (!action) return null; + const label = action === "import-mail" ? tFiles2("import_to_mail") : action === "add-calendar" ? tFiles2("add_to_calendar") : tFiles2("import_contacts"); + return { kind: action, label, onRun: () => void runAppAction(r) }; + }, [appActionOf, runAppAction, tFiles2]); + const handleRenameNode = useCallback(async (id: string, newName: string) => { try { if (!await renameNode(id, newName)) return; @@ -784,29 +835,31 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { // The previewed file as plugins see it (file-preview-section). const previewedName = previewFile ?? previewImage; - // ←/→ in a preview go through the folder's other previewable files. + // ←/→ in a preview go through the folder's other files that open in one: + // previews and the pane of a file with no preview (pim-p2-18). Office + // documents open the editor, so the arrows pass them by. + const shownName = previewedName ?? infoFile; const previewNeighbours = useMemo(() => { - if (!previewedName) return undefined; + if (!shownName) return undefined; const names = resources - .filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name))) + .filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name) || !isOfficeEditable(r.name))) .map((r) => r.name); - const i = names.indexOf(previewedName); + const i = names.indexOf(shownName); if (i === -1) return undefined; return { previous: names[i - 1] ?? null, next: names[i + 1] ?? null, onGo: (name: string) => { addRecentFile(name, resources.find((r) => r.name === name)?.id ?? name); - if (isImageFile(name)) { - setPreviewFile(null); - setPreviewImage(name); - } else { - setPreviewImage(null); - setPreviewFile(name); - } + setPreviewFile(null); + setPreviewImage(null); + setInfoFile(null); + if (isImageFile(name)) setPreviewImage(name); + else if (isFilePreviewable(name)) setPreviewFile(name); + else setInfoFile(name); }, }; - }, [previewedName, resources, addRecentFile]); + }, [shownName, resources, addRecentFile, isOfficeEditable]); const previewedView = useMemo(() => { const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined; return resource ? resourceToView(resource, currentPath) : null; @@ -926,7 +979,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{ + if (e.defaultPrevented || !canTakeDrops || isInternalDrag(e) || !Array.from(e.dataTransfer.types ?? []).includes("Files")) return; + if (filesDropTarget() === null) return; + e.preventDefault(); + void getDroppedFilesAndFolders(e.dataTransfer).then(({ files, hasDirectories }) => ( + hasDirectories ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files) + )); + }} + > {!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -1033,6 +1099,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onShowFileInfo={handleShowFileInfo} canOpenInApp={canOpenInApp} onOpenInApp={handleOpenInApp} + appActionFor={appActionFor} onShared={handleShared} /> )} @@ -1072,6 +1139,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { getFileContent={() => getFileContent(previewFile)} file={previewedView} neighbours={previewNeighbours} + extraAction={(() => { + const resource = resources.find((r) => r.name === previewFile); + const action = resource ? appActionFor(resource) : null; + return action ? { label: action.label, onClick: action.onRun } : undefined; + })()} /> )} @@ -1085,6 +1157,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onClose={() => setInfoFile(null)} onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)} onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined} + appAction={appActionFor(resource) ?? undefined} + neighbours={previewNeighbours} /> ); })()} @@ -1128,6 +1202,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { + setImportEml(null)} />
); diff --git a/components/files/import-eml-dialog.tsx b/components/files/import-eml-dialog.tsx new file mode 100644 index 000000000..0691b2a1f --- /dev/null +++ b/components/files/import-eml-dialog.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { MoveFolderList, useMoveMenu } from "@/components/email/move-target-list"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; + +/** + * "Import to a mail folder…" for a message saved in Files (pim-p2-7): the user's own folders + * that take new mail, in the Move list's order with its filter, and Email/import into the one + * picked, as a dropped .eml goes into the folder in view. + */ +export function ImportEmlDialog({ file, onClose }: { file: File | null; onClose: () => void }) { + const t = useTranslations("ux_p2_pim_files"); + const tSidebar = useTranslations("sidebar"); + const all = useEmailStore((s) => s.mailboxes); + const client = useAuthStore((s) => s.client); + const [busy, setBusy] = useState(false); + + // Files may be the first screen opened: the folders aren't loaded yet. + useEffect(() => { + if (!file || !client || useEmailStore.getState().mailboxes.length > 0) return; + void useEmailStore.getState().fetchMailboxes(client).catch(() => {}); + }, [file, client]); + + const mailboxes = useMemo( + () => all.filter((mailbox) => !mailbox.isShared && mailbox.myRights?.mayAddItems !== false), + [all], + ); + const model = useMoveMenu(mailboxes, { currentMailboxId: null }); + + const importInto = async (mailboxId: string) => { + const mailbox = mailboxes.find((entry) => entry.id === mailboxId); + if (!file || !client || !mailbox || busy) return; + const folder = localizeMailboxName(mailbox.role, mailbox.name, (key) => tSidebar(`mailboxes.${key}`)); + setBusy(true); + try { + await client.importRawEmail(file, { [mailbox.id]: true }, { $seen: true }, mailbox.accountId); + toast.success(t("imported_to_mail", { folder })); + onClose(); + } catch (error) { + console.error("Importing a message from Files failed:", error); + toast.error(t("import_to_mail_failed", { name: file.name })); + } finally { + setBusy(false); + } + }; + + return ( + +
+ void importInto(id)} /> +
+
+ ); +} diff --git a/lib/files-app-actions.ts b/lib/files-app-actions.ts new file mode 100644 index 000000000..4d41a23dc --- /dev/null +++ b/lib/files-app-actions.ts @@ -0,0 +1,22 @@ +/** + * What the app itself does with a file kept in Files (pim-p2-7): a saved message goes into a + * mail folder, a .csv or .vcf into contacts, an .ics into the calendar, through the same + * import dialogs a dropped file reaches. + */ + +export type FileAppAction = 'import-mail' | 'import-contacts' | 'add-calendar'; + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot).toLowerCase() : ''; +} + +export function fileAppAction(name: string, type?: string | null): FileAppAction | null { + const extension = extensionOf(name); + const mime = (type ?? '').split(';')[0].trim().toLowerCase(); + if (extension === '.eml' || mime === 'message/rfc822') return 'import-mail'; + if (extension === '.ics' || extension === '.ical' || mime === 'text/calendar') return 'add-calendar'; + if (extension === '.vcf' || extension === '.vcard' || mime === 'text/vcard' || mime === 'text/x-vcard') return 'import-contacts'; + if (extension === '.csv' || mime === 'text/csv') return 'import-contacts'; + return null; +} From a39aacc444c762c6d33e86a6a8c7be659fc9a157 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:34 +0100 Subject: [PATCH 5/6] fix: take dropped files whose items carry no file system entry --- .../__tests__/drop-utils-no-entries.test.ts | 16 ++++++++++++++++ lib/webdav/drop-utils.ts | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 lib/webdav/__tests__/drop-utils-no-entries.test.ts diff --git a/lib/webdav/__tests__/drop-utils-no-entries.test.ts b/lib/webdav/__tests__/drop-utils-no-entries.test.ts new file mode 100644 index 000000000..58b92efc9 --- /dev/null +++ b/lib/webdav/__tests__/drop-utils-no-entries.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getDroppedFilesAndFolders } from '../drop-utils'; + +// A drop whose items have no file system entry (files a page put into a +// DataTransfer itself) still hands over its files instead of nothing. + +describe('getDroppedFilesAndFolders', () => { + it('falls back to the files when no item has an entry', async () => { + const file = new File(['x'], 'a.txt'); + const transfer = { + items: [{ kind: 'file', webkitGetAsEntry: () => null }], + files: [file], + } as unknown as DataTransfer; + expect(await getDroppedFilesAndFolders(transfer)).toEqual({ files: [file], hasDirectories: false }); + }); +}); diff --git a/lib/webdav/drop-utils.ts b/lib/webdav/drop-utils.ts index 4b2d2e519..3288dac38 100644 --- a/lib/webdav/drop-utils.ts +++ b/lib/webdav/drop-utils.ts @@ -88,12 +88,15 @@ export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Pro const items = dataTransfer.items; // Check if the browser supports webkitGetAsEntry + const entries: FileSystemEntry[] = []; if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') { - const entries: FileSystemEntry[] = []; for (let i = 0; i < items.length; i++) { const entry = items[i].webkitGetAsEntry(); if (entry) entries.push(entry); } + } + // Files a page made itself have no entries: they come through `files` below. + if (entries.length > 0) { let hasDirectories = false; const filePromises: Promise[] = []; From 8b412dde766775759e30701be24f031f88176929 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:34 +0100 Subject: [PATCH 6/6] test: check drops, saved mail, .csv import and the row menu in Files on the server --- integration/tests/26-ux-p2-pim-files.spec.ts | 193 +++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 integration/tests/26-ux-p2-pim-files.spec.ts diff --git a/integration/tests/26-ux-p2-pim-files.spec.ts b/integration/tests/26-ux-p2-pim-files.spec.ts new file mode 100644 index 000000000..d712c554d --- /dev/null +++ b/integration/tests/26-ux-p2-pim-files.spec.ts @@ -0,0 +1,193 @@ +import { test, expect, type Page } from '@playwright/test'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2 in Files (pim-5, pim-p2-6, pim-p2-7, pim-p2-18) against a VM 106 + * Stalwart, 1.0 and 0.16. Every step that writes is read back with FileNode/get. + * + * - pim-p2-6: files dropped on the list go into the folder in view, files dropped on a + * folder's row into that folder, with no overlay and no question. + * - pim-p2-7: a saved .eml opens like a mail with "Import to a mail folder…"; a .csv + * offers "Import into contacts…", which opens the contacts import. + * - pim-5: at 1280×720 the ⋮ of the last row opens its menu beside the button, not over it. + * + * Account dso-ux-pim, password IT_DSO_PASSWORD. Every node made here is named + * "UXP2-files …" and removed afterwards. Nothing is imported. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXP2-files ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Node = { id: string; name: string; parentId: string | null; blobId: string | null }; + +async function nodes(c: JmapClient): Promise { + const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]); + return r.methodResponses[0][1].list as Node[]; +} + +async function ours(c: JmapClient): Promise { + return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX)); +} + +async function cleanUp(c: JmapClient): Promise { + // Files first, then folders: a folder with children may be refused. + for (let round = 0; round < 3; round++) { + const all = await ours(c); + if (all.length === 0) return; + const parents = new Set(all.map((n) => n.parentId)); + const leaves = all.filter((n) => !parents.has(n.id)).map((n) => n.id); + await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: leaves }, '0']], [CORE, FILENODE]); + } +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +async function openFilesRoot(page: Page): Promise { + await page.goto('/files'); + await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 }); +} + +async function upload(page: Page, files: { name: string; text: string; mimeType?: string }[]): Promise { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: f.mimeType ?? 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +/** A drag from outside, as the browser sends it, ending in a drop on `target`. */ +async function dropFiles(page: Page, selector: string, files: { name: string; text: string }[]): Promise { + const transfer = await page.evaluateHandle((list) => { + const dt = new DataTransfer(); + for (const f of list) dt.items.add(new File([f.text], f.name, { type: 'text/plain' })); + return dt; + }, files); + const target = page.locator(selector).first(); + await target.dispatchEvent('dragenter', { dataTransfer: transfer }); + await target.dispatchEvent('dragover', { dataTransfer: transfer }); + // No choices on the Files screen. + await expect(page.getByTestId('file-drop-overlay')).toHaveCount(0); + await target.dispatchEvent('drop', { dataTransfer: transfer }); +} + +const EML = [ + 'From: Ben ', + 'To: dso-ux-pim@example.org', + `Subject: ${PREFIX} saved mail`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0100', + 'Message-ID: ', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Hello from a saved message.', + '', +].join('\r\n'); + +const CSV = 'First Name,Last Name,E-mail Address\r\nAnna,Example,anna.uxp2@example.org\r\n'; + +test.describe('Files, usability pass 2', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('a drop uploads where it lands, with no question (pim-p2-6)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const folderName = `${PREFIX} folder`; + const folderId = await a.createFileDirectory(folderName); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await expect(row(page, folderName)).toBeVisible({ timeout: 30000 }); + + // On the list: the folder in view (the top of Files). + await dropFiles(page, '[role="grid"][aria-label="Files and folders"]', [{ name: `${PREFIX} on list.txt`, text: 'list' }]); + await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on list.txt`)?.parentId ?? 'missing', { timeout: 30000 }) + .toBeNull(); + + // On the folder's row: into that folder. + await dropFiles(page, `[data-resource="${folderName}"]`, [{ name: `${PREFIX} on row.txt`, text: 'row' }]); + await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on row.txt`)?.parentId ?? 'missing', { timeout: 30000 }) + .toBe(folderId); + await context.close(); + }); + + test('a saved mail opens as a mail, a .csv offers the contacts import (pim-p2-7)', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const eml = `${PREFIX} saved.eml`; + const csv = `${PREFIX} contacts.csv`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await upload(page, [{ name: eml, text: EML, mimeType: 'message/rfc822' }, { name: csv, text: CSV, mimeType: 'text/csv' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name).filter((n) => n === eml || n === csv).sort(), { timeout: 30000 }) + .toEqual([csv, eml].sort()); + + await row(page, eml).dblclick(); + const preview = page.getByRole('dialog', { name: eml }); + await expect(preview).toBeVisible({ timeout: 30000 }); + await expect(preview.getByText(`${PREFIX} saved mail`).first()).toBeVisible({ timeout: 30000 }); + await expect(preview.getByRole('button', { name: 'Import to a mail folder…' })).toBeVisible(); + await expect(page.getByText('There is no preview for this type of file.')).toHaveCount(0); + await preview.getByRole('button', { name: 'Import to a mail folder…' }).click(); + await expect(page.getByTestId('import-eml-dialog')).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('import-eml-dialog')).toBeHidden(); + await page.keyboard.press('Escape'); + + await row(page, csv).dblclick(); + const csvPreview = page.getByRole('dialog', { name: csv }); + await expect(csvPreview.getByRole('button', { name: 'Import into contacts…' })).toBeVisible({ timeout: 30000 }); + await csvPreview.getByRole('button', { name: 'Import into contacts…' }).click(); + await expect(page).toHaveURL(/\/contacts/, { timeout: 30000 }); + await expect(page.getByRole('dialog', { name: 'Import contacts' })).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await context.close(); + }); + + test('the ⋮ of the last row opens beside its button at 1280×720 (pim-5)', async ({ browser }) => { + test.setTimeout(240_000); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1280, height: 720 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + const rows = page.locator('[role="grid"][aria-label="Files and folders"] [data-resource]'); + await expect(rows.first()).toBeVisible({ timeout: 30000 }); + const last = rows.last(); + await last.scrollIntoViewIfNeeded(); + await last.hover(); + const kebab = last.locator('[data-item-action][title]').last(); + const button = (await kebab.boundingBox())!; + await kebab.click(); + const menu = page.getByRole('menu').first(); + await expect(menu).toBeVisible(); + const box = (await menu.boundingBox())!; + const overlaps = box.y < button.y + button.height && box.y + box.height > button.y + && box.x < button.x + button.width && box.x + box.width > button.x; + expect(overlaps).toBe(false); + await page.keyboard.press('Escape'); + await context.close(); + }); +});