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 01/29] =?UTF-8?q?fix:=20open=20the=20Files=20=E2=8B=AE=20m?= =?UTF-8?q?enus=20beside=20their=20button,=20above=20it=20when=20there=20i?= =?UTF-8?q?s=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 b588954ea4b90c5a9e1772877f80b5a152a46454 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:42:36 +0100 Subject: [PATCH 02/29] feat: pick an event's time zone by city with search, and open Duplicate as an unsaved copy at the same time The editor's time zone test now picks a zone through the searchable picker instead of a select's change event, since the select is gone. --- .../calendar-duplicate-ux-p2-pim.test.tsx | 156 ++++++++++++++++++ .../event-modal-time-zone-ux-pim.test.tsx | 47 +++++- components/calendar/calendar-app.tsx | 94 +++++------ components/calendar/event-modal.tsx | 106 +++++++----- components/settings/time-zone-picker.tsx | 27 ++- components/ui/searchable-select.tsx | 7 +- lib/__tests__/calendar-duplicate.test.ts | 60 ------- lib/calendar-duplicate.ts | 35 ---- lib/event-time-zone.ts | 5 +- 9 files changed, 336 insertions(+), 201 deletions(-) create mode 100644 components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx delete mode 100644 lib/__tests__/calendar-duplicate.test.ts delete mode 100644 lib/calendar-duplicate.ts diff --git a/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx new file mode 100644 index 000000000..1902e8850 --- /dev/null +++ b/components/calendar/__tests__/calendar-duplicate-ux-p2-pim.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useTaskStore } from '@/stores/task-store'; + +// The whole calendar page, with the shell around it stubbed out and the +// stores holding a fixed set of calendars and events. + +const media = vi.hoisted(() => ({ mobile: false, desktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => media.mobile, + useIsDesktop: () => !media.mobile, + // `desktop` is the full desktop: below it the sidebar opens over the page. + useIsFullDesktop: () => media.desktop, + useIsTablet: () => !media.mobile && !media.desktop, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +const config = vi.hoisted(() => ({ demoMode: false })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); +const lite = vi.hoisted(() => ({ on: false })); +vi.mock('@/lib/lite', async (importOriginal) => ({ + ...(await importOriginal()), + get IS_LITE() { return lite.on; }, +})); + +import { CalendarApp } from '../calendar-app'; + +// The first render loads the whole page and is slow under load. +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); + useTaskStore.setState({ tasks: [] } as never); + client.getCalendarTasks.mockResolvedValue([]); +} + +function renderApp() { + return render(); +} + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + setUp([makeEvent({ + description: 'Agenda in the doc', + locations: { l: { '@type': 'Location', name: 'Room 4' } }, + participants: { + me: { '@type': 'Participant', email: 'me@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as never)]); + useCalendarStore.setState({ participantIdentities: [{ id: 'pi', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true }] } as never); +}); + +// pim-p2-17: Duplicate saved a copy one day later at once and opened it to +// view, so a mistaken click left an event behind. Google and Outlook open the +// editor with an unsaved copy at the same time instead. +describe('Duplicate opens an unsaved copy in the editor', () => { + async function duplicateFromMenu() { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'events.duplicate' })); + return screen.findByRole('dialog', { name: 'events.create' }); + } + + it('saves nothing until Save, and the form holds the copy at the same date and time', async () => { + await duplicateFromMenu(); + expect((screen.getByLabelText('form.title') as HTMLInputElement).value).toBe('Standup'); + expect((screen.getByLabelText('form.start_date') as HTMLInputElement).value).toBe('2026-10-12'); + expect((screen.getByLabelText('form.start_time') as HTMLInputElement).value).toBe('10:00'); + expect(store.createEvent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + const data = store.createEvent.mock.calls[0][1] as Partial; + expect(data).toMatchObject({ title: 'Standup', start: '2026-10-12T10:00:00', calendarIds: { cal: true }, description: 'Agenda in the doc' }); + expect(data.uid).toBeTruthy(); + expect(data.uid).not.toBe('u1'); + expect(Object.values(data.locations ?? {})[0]?.name).toBe('Room 4'); + const emails = Object.values(data.participants ?? {}).map((p) => p.email); + expect(emails).toContain('ben@example.com'); + // The copy is the user's own: the user organizes it. + const owner = Object.values(data.participants ?? {}).find((p) => p.roles?.owner); + expect(owner?.email).toBe('me@example.com'); + }); + + it('leaves nothing behind when the copy is closed', async () => { + await duplicateFromMenu(); + fireEvent.click(screen.getByRole('button', { name: 'form.cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'events.create' })).toBeNull()); + expect(store.createEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx index 5ce88cfed..254543683 100644 --- a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -50,7 +50,16 @@ async function save(onSave: ReturnType): Promise<{ data: Partial screen.getByLabelText(label) as HTMLInputElement; +// The picker is Settings' searchable one (pim-p2-5): type a city, pick it. +async function pickZone(city: string) { + const search = await screen.findByRole('combobox', { name: 'time_zone_search' }); + fireEvent.change(search, { target: { value: city } }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`^${city}`) })); +} + beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +// jsdom has no layout; the picker scrolls its active option into view. +beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); }); afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); describe('the event editor keeps the event in its own time zone (pim-2)', () => { @@ -109,7 +118,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => // A new event is in the user's zone, behind a small link. expect(screen.queryByText(/^time_zone_line/)).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'time_zone' })); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); // 10:00 in Tokyo is 01:00 UTC. @@ -119,7 +128,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => it('tells the series save when the user picked another zone', async () => { const onSave = editor(newYork); fireEvent.click(screen.getByText(/^time_zone_line:/)); - fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + await pickZone('Tokyo'); const { data, state } = await save(onSave); expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' }); expect(state.timeZoneChanged).toBe(true); @@ -145,6 +154,40 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () => }); }); +describe('the time zone picker is the one Settings has (pim-p2-5)', () => { + it('lists cities with their offset, common ones first, and no "Automatic"', async () => { + editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + const options = await screen.findAllByRole('option'); + const names = options.map((o) => o.textContent ?? ''); + // Common zones lead: UTC, then Los Angeles; no raw ids like "Africa/Abidjan" first. + expect(names[0]).toMatch(/^UTC/); + expect(names.some((n) => n.startsWith('New York') && /GMT-0[45]:00/.test(n))).toBe(true); + expect(names.some((n) => n.includes('/'))).toBe(false); + expect(screen.queryByText(/time_zone\.auto/)).toBeNull(); + }); + + it('finds a zone by typing its city and saves the IANA id', async () => { + const onSave = editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + await pickZone('Kolkata'); + const { data } = await save(onSave); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(data.timeZone); + }); + + it("shows an event's old zone id by its current city name", () => { + editor({ ...newYork, timeZone: 'Europe/Kiev' } as CalendarEvent); + expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"Kyiv"}'); + }); + + it('gives the "Time zone" link the link colour, not the near-white primary of the dark theme (pim-p2-19)', () => { + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + const link = screen.getByRole('button', { name: 'time_zone' }); + expect(link.className).toContain('dark:text-blue-400'); + expect(link.className).not.toContain('text-primary'); + }); +}); + describe('the event form order (pim-7)', () => { it('asks for the time right after the title and for the description last', () => { editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..c5ea1a0af 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -60,7 +60,6 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { useEventUserEmails } from "@/hooks/use-event-user-emails"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { descriptionWithNote } from "@/lib/calendar-description"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; @@ -68,7 +67,7 @@ import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/l import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { getClientByLocalAccountId } from "@/stores/client-registry"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; -import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; @@ -280,6 +279,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [quickDraft, setQuickDraft] = useState(null); const [defaultModalTitle, setDefaultModalTitle] = useState(); const [modalStartsInEdit, setModalStartsInEdit] = useState(false); + // Duplicate opens the editor on an unsaved copy of this event (pim-p2-17). + const [copySource, setCopySource] = useState(null); + useEffect(() => { + if (!showEventModal) setCopySource(null); + }, [showEventModal]); const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); @@ -740,6 +744,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setQuickDraft(null); setDefaultModalTitle(undefined); setEditEvent(null); + setCopySource(null); const d = date || selectedDate; setDefaultModalDate(d); setDefaultModalEndDate(endDate); @@ -752,6 +757,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => { setModalStartsInEdit(startInForm); + setCopySource(null); setEditEvent(event); setDefaultModalDate(undefined); setShowTaskModal(false); @@ -1335,24 +1341,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = }; }, [quickDraft, pendingPreview, t]); - const handleDuplicateEvent = useCallback(async (data: Partial) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - try { - const created = await createEvent(client, data); - if (!created) { - toast.error(t("notifications.event_error")); - return; - } - focusCalendarOnEvent(created); - toast.success(t("notifications.event_duplicated")); - setEditEvent(created); - setDefaultModalDate(undefined); - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - setShowEventModal(false); - setEditEvent(null); - } - }, [client, createEvent, focusCalendarOnEvent, describeError, t]); + // Duplicate, from every place that offers it: the editor opens on a copy at + // the same date and time, as in Google and Outlook. Nothing is saved until + // Save, so a mistaken click leaves nothing behind (pim-p2-17). The copy goes + // to the original's calendar when the user can write there. + const handleDuplicateEvent = useCallback((event: CalendarEvent) => whenEditorsDiscarded(() => { + const primary = getPrimaryCalendarId(event); + const calendar = calendars.find((c) => c.id === primary); + setDefaultCalendarIdForCreate(calendar && !calendar.isTasksOnly && canCreateEventsIn(calendar, isSubscriptionCalendar) ? calendar.id : undefined); + setEditEvent(null); + setCopySource(event); + setModalStartsInEdit(true); + setDefaultModalDate(undefined); + setDefaultModalEndDate(undefined); + setDefaultModalTitle(undefined); + setShowTaskModal(false); + setEditTask(null); + setShowEventModal(true); + }), [calendars, isSubscriptionCalendar, whenEditorsDiscarded]); const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => { if (!client) { toast.error(t("notifications.event_error")); return; } @@ -1540,20 +1546,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(detailEvent.id, hasParticipants || undefined); }, [detailEvent, closeDetail, handleDeleteEvent]); - const handleDuplicateFromDetail = useCallback(async () => { - if (!detailEvent || !client) return; - const data = buildDuplicateEventData(detailEvent); + const handleDuplicateFromDetail = useCallback(() => { + if (!detailEvent) return; + const source = detailEvent; closeDetail(); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [detailEvent, client, createEvent, closeDetail, openEditModal, describeError, t]); + handleDuplicateEvent(source); + }, [detailEvent, closeDetail, handleDuplicateEvent]); const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; @@ -1568,19 +1566,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [detailEvent, client, updateEvent, describeError, t]); - const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { - if (!client) { toast.error(t("notifications.event_error")); return; } - const data = buildDuplicateEventData(event); - try { - const created = await createEvent(client, data); - if (created) { - toast.success(t("notifications.event_duplicated")); - openEditModal(created); - } - } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); - } - }, [client, createEvent, openEditModal, describeError, t]); + const handleDuplicateContextMenu = useCallback((event: CalendarEvent) => { + handleDuplicateEvent(event); + }, [handleDuplicateEvent]); const handleExportICS = useCallback((event: CalendarEvent) => { try { @@ -2426,8 +2414,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {!isMobile && showEventModal && (
openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} - currentUserEmails={userEmailsFor(editEvent)} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={false} @@ -2593,8 +2582,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {showEventModal && isMobile && ( openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} - onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} - currentUserEmails={userEmailsFor(editEvent)} + onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + currentUserEmails={userEmailsFor(editEvent ?? copySource)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={true} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 5fe1e9335..e9e17d4e6 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -19,7 +19,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { TimeZonePicker } from "@/components/settings/time-zone-picker"; import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -40,7 +41,6 @@ import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/r import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; -import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; @@ -85,7 +85,13 @@ interface EventModalProps { */ onSave: (data: Partial, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; - onDuplicate?: (data: Partial) => void; + /** Opens the editor on an unsaved copy of the event (copyOf). */ + onDuplicate?: (event: CalendarEvent) => void; + /** + * A new event that starts as a copy of this one: same date and time, text, + * places, repeat, alerts and guests. Nothing is saved until Save (pim-p2-17). + */ + copyOf?: CalendarEvent | null; /** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */ onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void; onClose: () => void; @@ -266,6 +272,7 @@ export function EventModal({ onSave, onDelete, onDuplicate, + copyOf = null, onRsvp, onClose, onPreviewChange, @@ -299,6 +306,8 @@ export function EventModal({ ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"; const isEdit = !!event; + // What the form starts from: the event, or for a copy the event copied. + const seed = event ?? copyOf; const formatEventDate = useFormatEventDate(); const { formatDateRangeLabel } = useCalendarLocale(); const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit"); @@ -346,9 +355,9 @@ export function EventModal({ }, [event, currentUserEmails]); const existingParticipants = useMemo(() => { - if (!event) return []; - return getParticipantList(event, { resolveName: resolveContactName }); - }, [event, resolveContactName]); + if (!seed) return []; + return getParticipantList(seed, { resolveName: resolveContactName }); + }, [seed, resolveContactName]); const organizerInfo = useMemo(() => { if (!event?.participants) return null; @@ -357,7 +366,7 @@ export function EventModal({ }, [event, existingParticipants]); const getInitialStart = (): Date => { - if (event?.start) return getEventStartDate(event); + if (seed?.start) return getEventStartDate(seed); if (defaultDate) { const d = new Date(defaultDate); if (defaultEndDate) return d; @@ -371,31 +380,31 @@ export function EventModal({ }; const getInitialEnd = (): Date => { - if (event?.start) { - if (event.showWithoutTime) { - return getEventDisplayEndDate(event); + if (seed?.start) { + if (seed.showWithoutTime) { + return getEventDisplayEndDate(seed); } - return getEventEndDate(event); + return getEventEndDate(seed); } if (defaultEndDate) return new Date(defaultEndDate); return addHours(getInitialStart(), 1); }; - const [title, setTitle] = useState(event?.title || defaultTitle || ""); + const [title, setTitle] = useState(seed?.title || defaultTitle || ""); // An HTML description is shown and edited as its text. - const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]); + const eventDescription = useMemo(() => (seed ? descriptionAsText(seed) : ""), [seed]); const [description, setDescription] = useState(eventDescription); const [location, setLocation] = useState( - event?.locations ? Object.values(event.locations)[0]?.name || "" : "" + seed?.locations ? Object.values(seed.locations)[0]?.name || "" : "" ); const [virtualLocation, setVirtualLocation] = useState( - event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : "" + seed?.virtualLocations ? Object.values(seed.virtualLocations)[0]?.uri || "" : "" ); // The zone the date and time fields are in, and a save writes: the // event's own, the user's for a new event, none for a floating one. An // edit used to save every event in the user's zone (pim-2). const userTimeZone = getEffectiveTimeZone(); - const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(event, userTimeZone)); + const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(seed, userTimeZone)); const [showZonePicker, setShowZonePicker] = useState(false); const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone)); const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone)); @@ -403,14 +412,9 @@ export function EventModal({ const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall)); const [endDate, setEndDate] = useState(formatDateInput(initialEndWall)); const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall)); - const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); + const [allDay, setAllDay] = useState(seed?.showWithoutTime || defaultAllDay || false); // The zone the fields are on: none for an all-day event. const fieldZone = allDay ? null : timeZone; - const zoneOptions = useMemo(() => { - if (!showZonePicker) return []; - const zones = listTimeZones(); - return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones; - }, [showZonePicker, timeZone]); // The form's times on the user's clock, beside another zone's. const formYourTimes = useMemo(() => { if (!fieldZone || fieldZone === userTimeZone) return null; @@ -461,13 +465,13 @@ export function EventModal({ [calendars, isSubscriptionCalendar, calendarId, eventLogin] ); const [recurrence, setRecurrence] = useState(() => { - if (!event?.recurrenceRules?.length) return "none"; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return "none"; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom"; }); const [customRule, setCustomRule] = useState(() => { - if (!event?.recurrenceRules?.length) return null; - const rule = event.recurrenceRules[0]; + if (!seed?.recurrenceRules?.length) return null; + const rule = seed.recurrenceRules[0]; return isSimpleRecurrenceRule(rule) ? null : rule; }); // The recurrence the editor opened with: a rule the user did not touch is @@ -495,11 +499,11 @@ export function EventModal({ [customRule, t, locale] ); // A new event starts on the calendar's default alerts. - const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true)); + const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (seed ? seed.useDefaultAlerts === true : true)); // Alerts the rows can't represent (absolute triggers, offsets after the // start or relative to the end) are kept here so they survive a save. const [initialAlerts] = useState(() => - event && !event.useDefaultAlerts ? alertRowsOf(event.alerts) : { rows: [], preserved: {} } + seed && !seed.useDefaultAlerts ? alertRowsOf(seed.alerts) : { rows: [], preserved: {} } ); const [preservedAlerts, setPreservedAlerts] = useState>(initialAlerts.preserved); const [alertRows, setAlertRows] = useState(initialAlerts.rows); @@ -538,7 +542,19 @@ export function EventModal({ // The attendees the editor opened with: a save for every occurrence of a // series applies only those the user added or removed. const [initialAttendees] = useState(() => { - if (!event?.participants) return []; + if (!seed?.participants) return []; + // A copy is the user's own event: everyone else is invited, its old + // organizer too, and the user isn't a guest of it. + if (!event) { + const mine = new Set(currentUserEmails.map((e) => e.toLowerCase())); + return existingParticipants.reduce<{ name: string; email: string }[]>((acc, p) => { + const key = p.email.trim().toLowerCase(); + if (!key || mine.has(key)) return acc; + mine.add(key); + acc.push({ name: p.name, email: p.email.trim() }); + return acc; + }, []); + } // Never seed the invite list with the organizer or with a repeated address: // handleSave writes the organizer separately, so either would round-trip // into a duplicate entry every time the event is saved (#731). The @@ -852,9 +868,13 @@ export function EventModal({ // invitation e-mails then render as a "Description" heading over blank space. // An HTML description the user did not touch stays as it was; one they // edited is saved as the plain text it now is. - const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim(); + const keepsHtml = !!seed && isHtmlDescription(seed) && trimmedDescription === eventDescription.trim(); if ((trimmedDescription || event) && !keepsHtml) { Object.assign(data, plainDescriptionFields(event, trimmedDescription)); + } else if (keepsHtml && !event) { + // A copy keeps the formatted description it was made from. + data.description = seed.description; + data.descriptionContentType = seed.descriptionContentType; } if (!event) { @@ -1004,7 +1024,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, seed, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). @@ -1021,7 +1041,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - onDuplicate(buildDuplicateEventData(event)); + onDuplicate(event); }, [event, onDuplicate]); const titleRef = useRef(null); @@ -1419,17 +1439,16 @@ export function EventModal({
{showZonePicker ? ( <> - - + onChange={setTimeZone} + withAuto={false} + label={tEvent("time_zone")} + className="w-full" + defaultOpen + /> ) : fieldZone !== userTimeZone ? (
@@ -1441,7 +1460,8 @@ export function EventModal({ )}
) : ( - )} diff --git a/components/settings/time-zone-picker.tsx b/components/settings/time-zone-picker.tsx index cf772ea58..fa0b4f93c 100644 --- a/components/settings/time-zone-picker.tsx +++ b/components/settings/time-zone-picker.tsx @@ -12,7 +12,23 @@ import { getEffectiveLocale } from '@/i18n/detect-locale'; * GMT+01:00"), the common zones first. Searching finds a zone by city, id, * offset ("+05:30", "UTC-5") or its name in the UI language. */ -export function TimeZonePicker({ value, onChange }: { value: string; onChange: (value: string) => void }) { +export function TimeZonePicker({ + value, + onChange, + withAuto = true, + label, + className = 'w-64 max-w-full', + defaultOpen, +}: { + value: string; + onChange: (value: string) => void; + /** "Automatic ()" first. Off where a zone has to be named, as for an event. */ + withAuto?: boolean; + /** The field's name, when it isn't Settings' "Time zone". */ + label?: string; + className?: string; + defaultOpen?: boolean; +}) { const t = useTranslations('settings.language_region'); const tz = useTranslations('uxs_general'); @@ -40,11 +56,11 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( group, }); return [ - { value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }, + ...(withAuto ? [{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }] : []), ...common.map(toOption(tz('time_zone_common'))), ...all.map(toOption(tz('time_zone_all'))), ]; - }, [zones, value, browserTimeZone, t, tz, offsets]); + }, [zones, value, browserTimeZone, t, tz, offsets, withAuto]); // "CET" and "GMT+1" find their zones first (settings-p2-20). const rank = useCallback( (query: string, option: SearchableOption, wordsMatch: boolean) => @@ -55,13 +71,14 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: ( const current = options.find((option) => option.value === value); return ( ( diff --git a/components/ui/searchable-select.tsx b/components/ui/searchable-select.tsx index 8eea60a78..3130ff67a 100644 --- a/components/ui/searchable-select.tsx +++ b/components/ui/searchable-select.tsx @@ -40,6 +40,8 @@ interface SearchableSelectProps { * their order. */ rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null; + /** Opens the list on mount, for a field that appears because the user asked to change it. */ + defaultOpen?: boolean; } /** @@ -58,10 +60,11 @@ export function SearchableSelect({ className, menuClassName, rank, + defaultOpen = false, }: SearchableSelectProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); const [query, setQuery] = useState(''); - const [active, setActive] = useState(0); + const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, options.findIndex((o) => o.value === value)) : 0)); const containerRef = useRef(null); const buttonRef = useRef(null); const inputRef = useRef(null); diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts deleted file mode 100644 index d6b739f3b..000000000 --- a/lib/__tests__/calendar-duplicate.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { CalendarEvent } from '@/lib/jmap/types'; -import { buildDuplicateEventData } from '../calendar-duplicate'; - -function makeEvent(overrides: Partial = {}): CalendarEvent { - return { - id: 'ev1', - uid: 'original-uid', - title: 'Standup', - description: 'Daily sync', - start: '2026-10-05T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - showWithoutTime: false, - calendarIds: { cal1: true }, - status: 'tentative', - ...overrides, - } as CalendarEvent; -} - -describe('buildDuplicateEventData', () => { - it('keeps the meeting link (#1170)', () => { - const virtualLocations = { - v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' }, - } as unknown as CalendarEvent['virtualLocations']; - const data = buildDuplicateEventData(makeEvent({ virtualLocations })); - expect(data.virtualLocations).toEqual(virtualLocations); - expect(data.virtualLocations).not.toBe(virtualLocations); - }); - - // Without the type the copy's HTML would be stored, and shown, as text. - it('keeps the format of an HTML description', () => { - const data = buildDuplicateEventData(makeEvent({ description: '

Daily

', descriptionContentType: 'text/html' })); - expect(data).toMatchObject({ description: '

Daily

', descriptionContentType: 'text/html' }); - }); - - it('moves a timed event one day later in its own time zone with a fresh uid', () => { - const data = buildDuplicateEventData(makeEvent()); - expect(data).toMatchObject({ - title: 'Standup', - start: '2026-10-06T09:30:00', - duration: 'PT30M', - timeZone: 'Europe/Berlin', - calendarIds: { cal1: true }, - status: 'confirmed', - }); - expect(data.uid).toBeTruthy(); - expect(data.uid).not.toBe('original-uid'); - expect(data.id).toBeUndefined(); - }); - - it('sends all-day copies as floating midnight starts', () => { - const data = buildDuplicateEventData(makeEvent({ - start: '2026-10-05T00:00:00', - duration: 'P1D', - showWithoutTime: true, - })); - expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null }); - }); -}); diff --git a/lib/calendar-duplicate.ts b/lib/calendar-duplicate.ts deleted file mode 100644 index 437d08345..000000000 --- a/lib/calendar-duplicate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addDays, format, parseISO } from "date-fns"; -import type { CalendarEvent } from "@/lib/jmap/types"; -import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; -import { generateUUID } from "@/lib/utils"; - -/** - * Create payload for a copy of `event` one day later. Shared by the editor, - * the detail popover and the context menu - each used to build its own copy - * and two of them dropped the meeting link (`virtualLocations`, #1170). - * The start stays in the event's own wall clock and time zone. - */ -export function buildDuplicateEventData(event: CalendarEvent): Partial { - const newStart = addDays(parseISO(event.start), 1); - const data: Partial = { - uid: generateUUID(), - title: event.title, - description: event.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: event.duration, - timeZone: event.timeZone, - showWithoutTime: event.showWithoutTime, - calendarIds: { ...event.calendarIds }, - status: "confirmed", - freeBusyStatus: event.freeBusyStatus, - privacy: event.privacy, - }; - // Without its type an HTML description would be stored as plain text. - if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType; - if (event.locations) data.locations = structuredClone(event.locations); - if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); - return sanitizeOutgoingCalendarEventData(data); -} diff --git a/lib/event-time-zone.ts b/lib/event-time-zone.ts index 20fa089f3..fd4261736 100644 --- a/lib/event-time-zone.ts +++ b/lib/event-time-zone.ts @@ -1,5 +1,6 @@ import { format, isSameDay } from "date-fns"; import type { CalendarEvent } from "@/lib/jmap/types"; +import { timeZoneCity as friendlyCity } from "@/lib/time-zone-choices"; import { canonicalTimeZone, fromDisplayDate, @@ -101,8 +102,8 @@ export function zoneWallDurationMs(a: WallClock, b: WallClock, zone: string | nu /** "America/New_York" as people say it: "New York". */ export function timeZoneCity(zone: string): string { - const last = zone.split("/").pop() || zone; - return last.replace(/_/g, " "); + // Settings' names (pim-p2-5): current city names ("Kyiv", not "Kiev"). + return friendlyCity(zone); } /** Whether the clocks in `a` and `b` read alike at `instant`. */ From 2c8c58ce080445b6b0b132757ad5a5f53a01f721 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:45:25 +0100 Subject: [PATCH 03/29] fix: never call a done or cancelled task overdue, and count overdue tasks in the sidebar as the list does --- .../task-done-not-overdue-ux-p2-pim.test.tsx | 75 +++++++++++++++++++ .../calendar/calendar-sidebar-panel.tsx | 9 +-- components/calendar/task-list-view.tsx | 26 +++++-- 3 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx new file mode 100644 index 000000000..eb7eb5ce2 --- /dev/null +++ b/components/calendar/__tests__/task-done-not-overdue-ux-p2-pim.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; + +// pim-p2-12: a completed task still said "Overdue" in red, and the sidebar +// counted tasks due earlier today as overdue while the list didn't. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', options).format(d), + }), +})); + +import { TaskListView, isTaskOverdue } from '../task-list-view'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; + +const calendars = [{ id: 'cal', name: 'To-do', color: '#123456', isTasksOnly: true }] as unknown as Calendar[]; + +function task(overrides: Partial): CalendarTask { + return { + id: 't', title: 'Pay the dentist invoice', calendarIds: { cal: true }, progress: 'needs-action', + priority: 0, due: '2026-10-20', showWithoutTime: true, ...overrides, + } as unknown as CalendarTask; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 10, 4, 15, 0)); +}); +afterEach(() => { + vi.useRealTimers(); + useTaskStore.setState({ tasks: [] } as never); +}); + +describe('done tasks are never overdue', () => { + it('shows a completed task past its date with the plain date in grey', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.queryByText('tasks.overdue')).toBeNull(); + const date = screen.getByText('Oct 20'); + expect(date.className).toContain('text-muted-foreground'); + expect(date.className).not.toMatch(/text-red/); + }); + + it('still warns about an open task past its date', () => { + render( {}} onToggleComplete={() => {}} />); + expect(screen.getByText('tasks.overdue')).toBeInTheDocument(); + }); + + it('says so for cancelled tasks too, and not for one due later today', () => { + expect(isTaskOverdue(task({ progress: 'cancelled' }))).toBe(false); + expect(isTaskOverdue(task({ progress: 'completed' }))).toBe(false); + expect(isTaskOverdue(task({}))).toBe(true); + expect(isTaskOverdue(task({ due: '2026-11-04T09:00:00', showWithoutTime: false }))).toBe(false); + }); + + it('counts overdue tasks in the sidebar by the same rule as the list', () => { + useSettingsStore.setState({ enableCalendarTasks: true } as never); + useTaskStore.setState({ + tasks: [ + task({ id: 'a' }), + task({ id: 'b', progress: 'completed' }), + // Due this morning: today, not overdue, as the list says. + task({ id: 'c', due: '2026-11-04T09:00:00', showWithoutTime: false }), + ], + } as never); + const client = { getAccountId: () => 'me' } as unknown as IJMAPClient; + render( {}} client={client} />); + expect(screen.getByText(/^1 tasks\.filter_overdue$/i)).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 98ad798c0..b48e67ebe 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -20,9 +20,8 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { taskListsOf } from "@/components/calendar/task-list-view"; +import { isTaskOverdue, taskListsOf } from "@/components/calendar/task-list-view"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels"; import { useCvdColor } from "@/hooks/use-cvd-color"; @@ -124,10 +123,8 @@ export function CalendarSidebarPanel({ const setViewMode = useCalendarStore((s) => s.setViewMode); const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]); - const overdueTaskCount = useMemo(() => { - const now = displayNow(); - return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; - }, [tasks]); + // The same rule as the task list's Overdue filter (pim-p2-12). + const overdueTaskCount = useMemo(() => tasks.filter(isTaskOverdue).length, [tasks]); const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 8e2391ead..f907b4186 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -123,9 +123,24 @@ function getTaskPriorityIcon(priority: number) { type FormatDate = ReturnType["dateTime"]; -function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate): { label: string; className: string } { +/** Done and cancelled tasks are never overdue (pim-p2-12); a task due today isn't yet. */ +export function isTaskOverdue(task: Pick): boolean { + if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; + const dueDate = parseISO(task.due); + return isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); +} + +export function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string, formatDate: FormatDate, done = false): { label: string; className: string } { const dueDate = parseISO(due); - const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + const overdue = !done && isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate); + + // A finished task's date is a fact, not a warning: plain date, grey. + if (done) { + const formatted = showWithoutTime + ? formatDate(dueDate, { month: "short", day: "numeric" }) + : formatDate(dueDate, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: timeFormat === "12h" }); + return { label: formatted, className: "text-muted-foreground" }; + } if (isDisplayToday(dueDate)) { return { @@ -182,10 +197,7 @@ export function filterTasksForList( result = result.filter(task => task.progress === "completed"); break; case "overdue": - result = result.filter(task => { - if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; - return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due)); - }); + result = result.filter(isTaskOverdue); break; } @@ -258,7 +270,7 @@ export function TaskListView({ const cal = calendars.find(c => task.calendarIds[c.id]); const isCompleted = task.progress === "completed"; const priorityIcon = getTaskPriorityIcon(task.priority); - const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null; + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate, task.progress === "completed" || task.progress === "cancelled") : null; return (
Date: Wed, 4 Nov 2026 13:47:50 +0100 Subject: [PATCH 04/29] fix: write an event's length in the app's language in its popover, as 1 Std. in German --- .../event-popover-duration-ux-p2-pim.test.tsx | 55 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 20 +++++-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx new file mode 100644 index 000000000..8968975ef --- /dev/null +++ b/components/calendar/__tests__/event-popover-duration-ux-p2-pim.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the German popover read "16:00 – 17:00 (1h)"; German writes "1 Std.". + +const lang = vi.hoisted(() => ({ locale: 'de' })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => lang.locale, + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +import { EventDetailPopover } from '../event-detail-popover'; + +const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function popover(duration: string) { + const event = { + id: 'e1', calendarIds: { cal: true }, title: 'Team sync', start: '2026-11-05T16:00:00', duration, + timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + } as unknown as CalendarEvent; + render( + , + ); +} + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => { + useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }); + lang.locale = 'de'; +}); + +describe('the popover writes the duration in the app language', () => { + it('in German', () => { + popover('PT1H'); + expect(screen.getByText('(1 Std.)')).toBeInTheDocument(); + expect(screen.queryByText('(1h)')).toBeNull(); + }); + + it('with hours and minutes', () => { + popover('PT1H30M'); + expect(screen.getByText('(1 Std. 30 Min.)')).toBeInTheDocument(); + }); + + it('in English', () => { + lang.locale = 'en'; + popover('PT45M'); + expect(screen.getByText('(45 min)')).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index a9c6529b0..b6cff7a0b 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -114,12 +114,20 @@ function computePosition( }; } -function formatDurationDisplay(minutes: number): string { - if (minutes < 60) return `${minutes}min`; +/** "1 hr 30 min", "1 Std. 30 Min.": the language's own short units (pim-p2-19). */ +export function formatDurationDisplay(minutes: number, locale: string): string { + const unit = (value: number, name: "hour" | "minute") => { + try { + return new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value); + } catch { + return `${value}${name === "hour" ? "h" : "min"}`; + } + }; const h = Math.floor(minutes / 60); const m = minutes % 60; - if (m === 0) return `${h}h`; - return `${h}h${m}min`; + if (h === 0) return unit(m, "minute"); + if (m === 0) return unit(h, "hour"); + return `${unit(h, "hour")} ${unit(m, "minute")}`; } function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { @@ -506,7 +514,7 @@ export function EventDetailPopover({
- ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
{zoned && zonedText && (
@@ -529,7 +537,7 @@ export function EventDetailPopover({ ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes, locale)})
)} From 109866dd4c6ed501ec4292f9f6f298a5e68e8770 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:48:47 +0100 Subject: [PATCH 05/29] fix: tell two accounts' calendars apart, keep events out of task lists, and start new events in the account on screen Calendar, task list and address book choosers are grouped under each account's address, the sidebar and Settings head each account by its address, and a second account's default calendar gets a colour of its own. Events can no longer go into a task list from the editor, quick create or import; the task editor's "List" offers task lists only. New events preselect the active account's last-used or default calendar, and the event menu has "Copy to
" for the other account. Two task editor tests change their fixtures and label: the select now lists task lists only and is labelled "List", so plain calendars no longer appear in it (pim-p2-13). --- .../calendar-targets-ux-p2-pim.test.tsx | 220 ++++++++++++++++ .../__tests__/task-lists-ux-pim.test.tsx | 6 +- .../__tests__/task-modal-calendars.test.tsx | 16 +- components/calendar/calendar-app.tsx | 67 ++++- components/calendar/calendar-select.tsx | 65 +++++ .../calendar/calendar-sidebar-panel.tsx | 16 +- components/calendar/event-context-menu.tsx | 14 ++ components/calendar/event-modal.tsx | 46 ++-- components/calendar/ical-import-modal.tsx | 42 ++-- components/calendar/quick-create-popover.tsx | 17 +- components/calendar/task-list-view.tsx | 6 +- components/calendar/task-modal.tsx | 45 ++-- .../contacts/address-book-picker-dialog.tsx | 17 +- components/contacts/contact-form.tsx | 12 +- components/contacts/contact-import-dialog.tsx | 10 +- .../calendar-management-settings.test.tsx | 26 ++ .../settings/calendar-management-settings.tsx | 15 +- lib/calendar-targets.ts | 234 ++++++++++++++++++ locales/ar/common.json | 7 + locales/ca/common.json | 7 + locales/cs/common.json | 7 + locales/da/common.json | 7 + locales/de/common.json | 7 + locales/en/common.json | 7 + locales/es/common.json | 7 + locales/et/common.json | 7 + locales/fa/common.json | 7 + locales/fr/common.json | 7 + locales/he/common.json | 7 + locales/hu/common.json | 7 + locales/it/common.json | 7 + locales/ja/common.json | 7 + locales/ko/common.json | 7 + locales/lv/common.json | 7 + locales/mn/common.json | 7 + locales/nb/common.json | 7 + locales/nl/common.json | 7 + locales/pl/common.json | 7 + locales/pt/common.json | 7 + locales/ro/common.json | 7 + locales/ru/common.json | 7 + locales/sk/common.json | 7 + locales/th/common.json | 7 + locales/tr/common.json | 7 + locales/uk/common.json | 7 + locales/vi/common.json | 7 + locales/zh-TW/common.json | 7 + locales/zh/common.json | 7 + stores/calendar-store.ts | 20 ++ 49 files changed, 991 insertions(+), 113 deletions(-) create mode 100644 components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx create mode 100644 components/calendar/calendar-select.tsx create mode 100644 lib/calendar-targets.ts diff --git a/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx new file mode 100644 index 000000000..8a96c1e40 --- /dev/null +++ b/components/calendar/__tests__/calendar-targets-ux-p2-pim.test.tsx @@ -0,0 +1,220 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { TaskModal } from '../task-modal'; +import { QuickCreatePopover } from '../quick-create-popover'; +import { ICalImportModal } from '../ical-import-modal'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { EventContextMenu } from '../event-context-menu'; +import { useAccountStore } from '@/stores/account-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useTaskStore } from '@/stores/task-store'; +import { buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors } from '@/lib/calendar-targets'; +import type { AddressBook, Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { ContactForm } from '@/components/contacts/contact-form'; +import { AddressBookPickerDialog } from '@/components/contacts/address-book-picker-dialog'; + +// Usability pass 2, pim-p2-2, p2-3, p2-4, p2-13 and p2-16: with two accounts +// (A and B, both signed in) the calendar choosers were one flat list by +// display name, offered A's task-only "To-do" for events, and preselected +// A's calendar while B was on screen. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const rights = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +function cal(id: string, name: string, login: string, extra: Partial = {}): Calendar { + return { + id: `${login}::${id}`, originalId: id, name, color: '#3b82f6', isDefault: false, isVisible: true, sortOrder: 0, + myRights: rights, localAccountId: login, ...extra, + } as unknown as Calendar; +} +const aDefault = cal('d', 'Stalwart Calendar (pim@example.org)', 'login-a', { isDefault: true }); +const aWork = cal('w', 'Work', 'login-a'); +const aTodo = cal('t', 'To-do', 'login-a', { isTasksOnly: true }); +const bDefault = cal('d', 'Stalwart Calendar (pim-b@example.org)', 'login-b', { isDefault: true }); +const calendars = [aDefault, aWork, aTodo, bDefault]; + +beforeEach(() => { + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'pim@example.org', username: 'pim@example.org', displayName: 'Pim Audit', label: 'Pim Audit' }, + { id: 'login-b', email: 'pim-b@example.org', username: 'pim-b@example.org', displayName: 'Pim Audit', label: 'Pim Audit' }, + ] as never, + activeAccountId: 'login-b', + }); + useCalendarStore.setState({ lastEventCalendarIds: {} }); + useTaskStore.setState({ tasks: [] } as never); +}); + +afterEach(() => { + useAccountStore.setState({ accounts: [], activeAccountId: null }); +}); + +function groupsOf(select: HTMLElement): Array<{ label: string; options: string[] }> { + return Array.from(select.querySelectorAll('optgroup')).map((g) => ({ + label: g.getAttribute('label') ?? '', + options: Array.from(g.querySelectorAll('option')).map((o) => o.textContent ?? ''), + })); +} + +const editorSelect = () => document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement; + +describe('event editor (pim-p2-2, p2-3, p2-4)', () => { + it("offers no task list, groups by account address, and starts in the active account's default calendar", () => { + render(); + const select = editorSelect(); + expect(groupsOf(select)).toEqual([ + { label: 'pim-b@example.org', options: ['default_calendar'] }, + { label: 'pim@example.org', options: ['default_calendar', 'Work'] }, + ]); + expect(select.value).toBe(bDefault.id); + }); + + it('starts in the calendar last picked in that account', () => { + useAccountStore.setState({ activeAccountId: 'login-a' }); + useCalendarStore.setState({ lastEventCalendarIds: { 'login-a': aWork.id } }); + render(); + expect(editorSelect().value).toBe(aWork.id); + }); + + it('keeps the one task list an existing event already sits in, and says so', () => { + const event = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Lost', description: '', start: '2026-11-05T07:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { [aTodo.id]: true }, + participants: null, alerts: null, recurrenceRules: null, localAccountId: 'login-a', + } as unknown as CalendarEvent; + render(); + fireEvent.click(screen.getByText('events.edit')); + const names = within(editorSelect()).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['default_calendar', 'Work', 'task_list_not_in_calendar']); + }); +}); + +describe('quick create (pim-p2-3)', () => { + it('groups the calendars by account address', () => { + render( + , + ); + expect(groupsOf(screen.getByLabelText('form.calendar_select')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); +}); + +describe('import (pim-p2-2, p2-4)', () => { + it("leaves the task list out and preselects the active account's calendar", async () => { + const client = { + getServerUrl: () => 'https://mail.example.org', + getCalendarsAccountId: () => 'acc', + uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })), + parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]), + }; + const file = new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' }); + render(); + await screen.findByText('Standup'); + const select = screen.getByLabelText('target_calendar') as HTMLSelectElement; + expect(within(select).getAllByRole('option').map((o) => o.textContent)).not.toContain('To-do'); + expect(select.value).toBe(bDefault.id); + }); +}); + +describe('task editor (pim-p2-13)', () => { + it('is labelled List and offers task lists only, grouped by account', () => { + const bTodo = cal('t', 'Errands', 'login-b', { isTasksOnly: true }); + render(); + const select = screen.getByLabelText('task_list_label') as HTMLSelectElement; + expect(groupsOf(select)).toEqual([ + { label: 'pim-b@example.org', options: ['Errands'] }, + { label: 'pim@example.org', options: ['To-do'] }, + ]); + // A new task goes to the account on screen. + expect(select.value).toBe(bTodo.id); + }); +}); + +describe('sidebar (pim-p2-3)', () => { + it("heads each account's group with its address, not the shared display name", () => { + useCalendarStore.setState({ viewMode: 'week' } as never); + render( {}} multiAccountMode />); + expect(screen.getAllByTestId('calendar-account-group').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); + + it("gives the second account's default calendar a colour of its own", () => { + const shown = distinctAccountDefaultColors(calendars); + const a = shown.find((c) => c.id === aDefault.id)!; + const b = shown.find((c) => c.id === bDefault.id)!; + expect(a.color).toBe('#3b82f6'); + expect(b.color).not.toBe(a.color); + // Nothing changes with one account. + expect(distinctAccountDefaultColors([aDefault, aWork])).toEqual([aDefault, aWork]); + }); +}); + +describe('copy to the other account (pim-p2-16)', () => { + const event = { + id: 'login-b::e1', originalId: 'e1', uid: 'u1', '@type': 'Event', title: 'Dentist', description: 'Bring card', + start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + calendarIds: { [bDefault.id]: true }, localAccountId: 'login-b', status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public', + participants: { p1: { calendarAddress: 'mailto:pim-b@example.org', roles: { owner: true } } }, + alerts: null, recurrenceRules: null, + } as unknown as CalendarEvent; + + it('offers the other account by address, into its default calendar', () => { + const targets = copyTargetsFor(event, [aDefault, aWork, bDefault], useAccountStore.getState().accounts); + expect(targets.map((x) => [x.account.email, x.calendar.id])).toEqual([['pim@example.org', aDefault.id]]); + }); + + it('copies the same time into the target calendar without its participants', () => { + const data = buildCopyToCalendarData(event, aDefault.id); + expect(data).toMatchObject({ title: 'Dentist', start: '2026-11-05T07:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [aDefault.id]: true } }); + expect(data.uid).not.toBe('u1'); + expect(data.participants).toBeUndefined(); + }); + + it('shows "Copy to
" in the event menu', () => { + const onCopy = vi.fn(); + const menuRef = { current: null }; + render( + , + ); + act(() => { fireEvent.click(screen.getByText('copy_to_account')); }); + expect(onCopy).toHaveBeenCalledTimes(1); + }); +}); + +describe('remembering the last calendar (pim-p2-4)', () => { + it('keeps each account its own pick across an account switch', () => { + useCalendarStore.getState().rememberEventCalendar(aWork); + useCalendarStore.getState().clearState(); + expect(useCalendarStore.getState().lastEventCalendarIds).toEqual({ 'login-a': aWork.id }); + }); +}); + +describe('address book choosers (pim-p2-3)', () => { + const books = [ + { id: 'login-a::ab', name: 'Stalwart Address Book (pim@example.org)', isDefault: true, localAccountId: 'login-a', myRights: { mayWrite: true } }, + { id: 'login-b::ab', name: 'Stalwart Address Book (pim-b@example.org)', isDefault: true, localAccountId: 'login-b', myRights: { mayWrite: true } }, + ] as unknown as AddressBook[]; + + it('groups the contact form\'s address books by account address', () => { + render(); + expect(groupsOf(screen.getByLabelText('section_address_book')).map((g) => g.label)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); + + it('heads the Move to picker with each account address', () => { + render(); + expect(screen.getAllByTestId('address-book-account').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']); + }); +}); diff --git a/components/calendar/__tests__/task-lists-ux-pim.test.tsx b/components/calendar/__tests__/task-lists-ux-pim.test.tsx index 5521596eb..0fc8d9ac9 100644 --- a/components/calendar/__tests__/task-lists-ux-pim.test.tsx +++ b/components/calendar/__tests__/task-lists-ux-pim.test.tsx @@ -123,8 +123,10 @@ describe('where a new task goes (pim-8)', () => { }); it('opens the task form on the task-only list', () => { - render( {}} />); - expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo'); + // The select lists task lists only (pim-p2-13), so a second one makes it show. + const errands = { ...todo, id: 'errands', name: 'Errands' } as Calendar; + render( {}} />); + expect((screen.getByLabelText('task_list_label') as HTMLSelectElement).value).toBe('todo'); }); }); diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx index 15eb01375..40aa0fd13 100644 --- a/components/calendar/__tests__/task-modal-calendars.test.tsx +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -4,22 +4,24 @@ import { TaskModal } from '../task-modal'; import type { Calendar, CalendarTask } from '@/lib/jmap/types'; // The task editor's Calendar select offered the read-only Birthdays -// calendar, which the event editor leaves out. +// calendar, which the event editor leaves out. Since pim-p2-13 it is "List" +// and offers task lists only. const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; const calendars = [ - { id: 'work', name: 'Work', isShared: false, myRights: writable }, - { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'work', name: 'Work', isShared: false, isTasksOnly: true, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, isTasksOnly: true, myRights: writable }, + { id: 'events', name: 'Events', isShared: false, isDefault: true, myRights: writable }, { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, - { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, + { id: 'feed', name: 'Holidays feed', isShared: false, isTasksOnly: true, myRights: writable }, ] as unknown as Calendar[]; const isSubscriptionCalendar = (id: string) => id === 'feed'; describe('task editor calendar select', () => { - it('lists only the calendars a task can go into', () => { + it('lists only the task lists a task can go into', () => { render(); - const select = screen.getByLabelText('tasks.calendar'); + const select = screen.getByLabelText('task_list_label'); const names = within(select).getAllByRole('option').map((o) => o.textContent); expect(names).toEqual(['Work', 'Personal']); }); @@ -30,7 +32,7 @@ describe('task editor calendar select', () => { progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, } as unknown as CalendarTask; render(); - const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + const select = screen.getByLabelText('task_list_label') as HTMLSelectElement; expect(select.value).toBe('birthdays'); }); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..5bbef9add 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -70,7 +70,8 @@ import { getClientByLocalAccountId } from "@/stores/client-registry"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; -import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; +import { accountAddress, buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors, eventTargets, preferredTarget, taskTargets } from "@/lib/calendar-targets"; +import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; @@ -172,6 +173,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // mail server for and the Lite static build does not have at all. const canSubscribe = !demoMode && !IS_LITE; const tDeepLink = useTranslations("deep_link"); + const tP2Cal = useTranslations("ux_p2_pim_cal"); const isMobile = useIsMobile(); // Below the full desktop the sidebar opens over the page. const isFullDesktop = useIsFullDesktop(); @@ -212,6 +214,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { identities } = useIdentityStore(); const contacts = useContactStore((s) => s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); + const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar); usePrintListener(); const printing = usePrinting(); const printTarget = usePrintStore((s) => s.target); @@ -1193,6 +1198,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = toast.error(t("notifications.event_error")); return; } + // The next new event in this account starts in the same calendar (pim-p2-4). + const pickedId = Object.keys(data.calendarIds ?? {})[0]; + const picked = pickedId ? useCalendarStore.getState().calendars.find((c) => c.id === pickedId) : undefined; + if (picked) rememberEventCalendar(picked); focusCalendarOnEvent(created); if (sendSchedulingMessages) { toast.success(t("notifications.invitation_sent")); @@ -1218,17 +1227,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }; await save(requestedScheduling); - }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]); + }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t, rememberEventCalendar]); - // Calendars a new task can go into: writable, not a subscription. + // Lists a new task can go into: task lists, writable, not a subscription (pim-p2-13). const writableTaskCalendars = useMemo( - () => calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)), - [calendars, isSubscriptionCalendar], + () => taskTargets(calendars, isSubscriptionCalendar, { holdingTasks: taskStore.tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})) }), + [calendars, isSubscriptionCalendar, taskStore.tasks], ); - // Calendars a new event can go into: never a subscription or read-only one (#762). + // Calendars a new event can go into: never a subscription or read-only one + // (#762), nor a task list (pim-p2-2). Coloured as the grid shows them. const writableCalendars = useMemo( - () => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)), + () => eventTargets(distinctAccountDefaultColors(calendars), isSubscriptionCalendar), [calendars, isSubscriptionCalendar], ); @@ -1240,7 +1250,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } const finalEnd = end ?? (allDay ? start : new Date(start.getTime() + 60 * 60 * 1000)); - const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0]; + // The account on screen's calendar, not the first account's (pim-p2-4). + const preferred = preferredTarget(writableCalendars, activeLocalAccountId, lastEventCalendarIds); setShowEventModal(false); setEditEvent(null); setShowTaskModal(false); @@ -1255,7 +1266,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ? prev.calendarId : preferred?.id ?? "", })); - }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded]); + }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded, activeLocalAccountId, lastEventCalendarIds]); // A day in the month view or the all-day row: an all-day event, the same // quick editor as a slot in week and day view. @@ -1616,6 +1627,35 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleDeleteEvent(event.id, hasParticipants || undefined); }, [handleDeleteEvent]); + // "Copy to " (pim-p2-16): an event can't move between + // accounts, so the other one gets a copy, and the toast offers to delete + // the original. A series is copied whole, read from its own server. + const localAccounts = useAccountStore((s) => s.accounts); + const copyTargetsOf = useCallback( + (event: CalendarEvent) => copyTargetsFor(event, writableCalendars, localAccounts), + [writableCalendars, localAccounts], + ); + const handleCopyToAccount = useCallback(async (event: CalendarEvent, target: { account: { email?: string; username?: string; id: string }; calendar: Calendar }) => { + if (!client) { toast.error(t("notifications.event_error")); return; } + let source = event; + if (event.recurrenceId && event.recurrenceRules?.length && event.originalId) { + const sourceClient = (event.localAccountId ? useAuthStore.getState().getClientForAccount(event.localAccountId) : null) ?? client; + const isSharedSource = calendars.some((c) => event.calendarIds?.[c.id] && c.isShared); + const master = await sourceClient.getCalendarEvent(event.originalId, isSharedSource ? event.accountId : undefined).catch(() => null); + if (master) source = master; + } + const address = accountAddress(target.account); + try { + const created = await createEvent(client, buildCopyToCalendarData(source, target.calendar.id)); + if (!created) return; + toast.success(tP2Cal("copied_to_account", { address }), { + action: { label: tP2Cal("delete_original"), onClick: () => handleDeleteContextMenu(event) }, + }); + } catch (error) { + toast.error(describeError(error, t("notifications.event_error"))); + } + }, [client, calendars, createEvent, describeError, t, tP2Cal, handleDeleteContextMenu]); + const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus'], target?: AnswerTarget) => { if (!detailEvent || !client) return; // A target names the event and participant: the owner's in a calendar @@ -1740,7 +1780,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // colors via the `colorIsLocalOverride` flag (see getEventColor). Personal // calendars are passed through untouched. const displayCalendars = useMemo(() => { - return calendars.map((cal) => { + // Two accounts' default calendars come in one blue: the second gets a colour of its own (pim-p2-3). + return distinctAccountDefaultColors(calendars).map((cal) => { if (!cal.isShared) return cal; const override = sharedCalendarColors[sharedCalendarColorKey(cal)]; if (!override) return cal; @@ -2087,7 +2128,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onQuickCreate={(title, due) => { // Into the first task-only list, else the default calendar, // else the first the user may write to (pim-8). - const target = defaultTaskList(writableTaskCalendars); + const target = defaultTaskList(writableTaskCalendars, activeLocalAccountId); if (!client || !target) { toast.error(t("notifications.task_save_error")); return; @@ -2519,6 +2560,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onRespond={gridAnswerFor(eventContextMenu.data) ? (status) => handleRespondFromGrid(eventContextMenu.data!, status) : undefined} responseStatus={gridAnswerFor(eventContextMenu.data)?.status} answersOnly={answersOnlyMenu} + copyTo={answersOnlyMenu ? [] : copyTargetsOf(eventContextMenu.data).map((target) => ({ + address: accountAddress(target.account), + onCopy: () => { void handleCopyToAccount(eventContextMenu.data!, target); }, + }))} /> )} diff --git a/components/calendar/calendar-select.tsx b/components/calendar/calendar-select.tsx new file mode 100644 index 000000000..a64617446 --- /dev/null +++ b/components/calendar/calendar-select.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo, type ChangeEvent } from "react"; +import { useTranslations } from "next-intl"; +import { useCollectionNames } from "@/hooks/use-collection-names"; +import { groupCalendarsByAccount } from "@/lib/calendar-targets"; +import type { Calendar } from "@/lib/jmap/types"; +import { useAccountStore } from "@/stores/account-store"; + +/** + * The options of any calendar or address book select, under each account's + * address when several accounts are signed in (pim-p2-3), the account on + * screen first. Goes inside the caller's own ) => onChange(e.target.value)} + aria-label={rest["aria-label"]} + title={title} + className={className} + data-testid="calendar-select" + > + + + ); +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 98ad798c0..fb526ae2d 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -12,6 +12,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; import { useAccountStore } from "@/stores/account-store"; +import { accountAddress } from "@/lib/calendar-targets"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors"; import { toast } from "@/stores/toast-store"; @@ -209,13 +210,15 @@ export function CalendarSidebarPanel({ list.push(cal); byAccount.set(key, list); } - const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = []; + const ordered: { key: string; label: string; name: string; split: AccountCalendarSplit }[] = []; // Active account first. if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { const acct = localAccounts.find(a => a.id === activeLocalAccountId); ordered.push({ key: activeLocalAccountId, - label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, + // By address (pim-p2-3): two accounts of one person share a name. + label: accountAddress(acct) || activeLocalAccountId, + name: acct?.displayName || acct?.label || '', split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!), }); byAccount.delete(activeLocalAccountId); @@ -225,7 +228,8 @@ export function CalendarSidebarPanel({ if (!byAccount.has(acct.id)) continue; ordered.push({ key: acct.id, - label: acct.label || acct.email || acct.username, + label: accountAddress(acct), + name: acct.displayName || acct.label || '', split: splitAccountCalendars(byAccount.get(acct.id)!), }); byAccount.delete(acct.id); @@ -235,7 +239,7 @@ export function CalendarSidebarPanel({ const fallbackLabel = key === '__other__' ? t('my_calendars') : list[0]?.accountName || key; - ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) }); + ordered.push({ key, label: fallbackLabel, name: '', split: splitAccountCalendars(list) }); } return ordered; }, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]); @@ -295,7 +299,7 @@ export function CalendarSidebarPanel({ const taskLists = useMemo(() => (inTasksView ? taskListsOf(calendars, tasks) : []), [inTasksView, calendars, tasks]); const accountLabel = (localAccountId: string) => { const acct = localAccounts.find(a => a.id === localAccountId); - return acct?.label || acct?.email || acct?.username || localAccountId; + return accountAddress(acct) || localAccountId; }; const taskListGroups = useMemo(() => { if (!multiAccountMode) return [{ key: '__all__', label: '', lists: taskLists }]; @@ -615,7 +619,7 @@ export function CalendarSidebarPanel({ )} - + ` : group.label} data-testid="calendar-account-group"> {group.label} {isActive && onCreateCalendar && ( diff --git a/components/calendar/event-context-menu.tsx b/components/calendar/event-context-menu.tsx index ff3483879..7b8148e27 100644 --- a/components/calendar/event-context-menu.tsx +++ b/components/calendar/event-context-menu.tsx @@ -18,6 +18,7 @@ import { Check, HelpCircle, X, + CalendarPlus, } from "@/components/icons"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { buildCalendarPath } from "@/lib/deep-links"; @@ -54,6 +55,8 @@ interface EventContextMenuProps { responseStatus?: CalendarParticipant["participationStatus"] | null; /** Only the answers: the menu of the "?" / "Reply" marker. */ answersOnly?: boolean; + /** "Copy to
", one per other signed-in account (pim-p2-16). */ + copyTo?: Array<{ address: string; onCopy: () => void }>; } export function EventContextMenu({ @@ -73,7 +76,9 @@ export function EventContextMenu({ onRespond, responseStatus, answersOnly = false, + copyTo = [], }: EventContextMenuProps) { + const tP2 = useTranslations("ux_p2_pim_cal"); const t = useTranslations("calendar"); const tDeepLink = useTranslations("deep_link"); const tCommon = useTranslations("common"); @@ -118,6 +123,15 @@ export function EventContextMenu({ )} {editable && } + {copyTo.map((target) => ( + + ))} { setStartDate(v); shiftEndKeepingDuration(v, startTime); }; const handleStartTimeChange = (v: string) => { setStartTime(v); shiftEndKeepingDuration(startDate, v); }; + // New events go to the account on screen (pim-p2-4): the calendar last + // picked there, else its default one. Never a task list (pim-p2-2). + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; - if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; - const defaultCal = calendars.find(c => c.isDefault); + const targets = eventTargets(calendars, isSubscriptionCalendar); + if (defaultCalendarId && targets.some(c => c.id === defaultCalendarId)) return defaultCalendarId; // Never default new events into a subscription / read-only calendar (#762). - return defaultCal?.id - || calendars.find(c => canCreateEventsIn(c, isSubscriptionCalendar))?.id + return preferredTarget(targets, activeLocalAccountId, lastEventCalendarIds)?.id || calendars[0]?.id || ""; }); - // Calendars offered as create/move targets: writable, non-subscription, plus - // the event's current calendar so an in-place edit never blanks the select. + // Calendars offered as create/move targets: writable, non-subscription, + // holding events (a task list never shows in the grid, so an event saved + // there seemed lost, pim-p2-2), plus the event's current calendar so an + // in-place edit never blanks the select. // An existing event moves only within its own login: the update goes to // that login's server, which doesn't know another login's calendar ids. const eventLogin = useMemo(() => { const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined; - return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined }; + return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId, primary } : { known: false, login: undefined, primary: undefined }; }, [event, calendars]); const selectableCalendars = useMemo( - () => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId) - && (!eventLogin.known || c.localAccountId === eventLogin.login)), - [calendars, isSubscriptionCalendar, calendarId, eventLogin] + () => eventTargets(calendars, isSubscriptionCalendar, eventLogin.primary) + .filter(c => !eventLogin.known || c.localAccountId === eventLogin.login), + [calendars, isSubscriptionCalendar, eventLogin] ); const [recurrence, setRecurrence] = useState(() => { if (!event?.recurrenceRules?.length) return "none"; @@ -1521,18 +1528,13 @@ export function EventModal({ {selectableCalendars.length > 1 && (
- + />
)} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index a66c77c94..8544c776d 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useRef, useId } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react"; import { useLocale, useTranslations } from "next-intl"; import { buildRecurrenceSummary } from "./recurrence-editor"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,9 @@ import { apiFetch } from "@/lib/browser-navigation"; import { explainServerAuthError } from "@/lib/server-auth-status"; import { IS_LITE } from "@/lib/lite"; import { isOfflineNow } from "@/lib/offline/state"; -import { useCollectionNames } from "@/hooks/use-collection-names"; +import { eventTargets, preferredTarget } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; interface ICalImportModalProps { calendars: Calendar[]; @@ -34,8 +36,15 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { - const shownName = useCollectionNames(); +export function ICalImportModal({ calendars: allCalendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { + const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + // Events go only where the grid shows them: never a task list or a feed (pim-p2-2). + const calendars = useMemo(() => eventTargets(allCalendars, isSubscriptionCalendar), [allCalendars, isSubscriptionCalendar]); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const rememberEventCalendar = useCalendarStore((s) => s.rememberEventCalendar); + // The account on screen's calendar, the one last picked there first (pim-p2-4). + const lastEventCalendarIds = useCalendarStore((s) => s.lastEventCalendarIds); + const preferredId = () => preferredTarget(calendars, activeLocalAccountId, lastEventCalendarIds)?.id ?? ""; const t = useTranslations("calendar.import"); const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); @@ -47,10 +56,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia const [step, setStep] = useState("select"); const [parsedEvents, setParsedEvents] = useState[]>([]); const [selectedIndices, setSelectedIndices] = useState>(new Set()); - const [calendarId, setCalendarId] = useState(() => { - const defaultCal = calendars.find((c) => c.isDefault); - return defaultCal?.id || calendars[0]?.id || ""; - }); + const [calendarId, setCalendarId] = useState(preferredId); const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); @@ -69,7 +75,8 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia // Opened before the calendars had loaded, from a launch: the default once they have. useEffect(() => { if (calendarId || calendars.length === 0) return; - setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id); + setCalendarId(preferredId()); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preferredId reads the same calendars }, [calendars, calendarId]); const validateFile = useCallback((file: File): string | null => { @@ -244,13 +251,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia setStep("preview"); return; } + const target = calendars.find((c) => c.id === calendarId); + if (target) rememberEventCalendar?.(target); toast.success(t("success", { count })); onClose(); } catch { toast.error(t("error")); setStep("preview"); } - }, [parsedEvents, selectedIndices, importEvents, client, calendarId, t, onClose]); + }, [parsedEvents, selectedIndices, importEvents, client, calendarId, calendars, rememberEventCalendar, t, onClose]); const formatEventDate = (event: Partial): string => { if (!event.start) return ""; @@ -459,18 +468,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia - + />
)} diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index 9951c9324..913afae41 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -12,6 +12,7 @@ import type { Calendar } from "@/lib/jmap/types"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; export interface QuickCreateDraft { start: Date; @@ -71,6 +72,7 @@ export function QuickCreatePopover({ onClose, }: QuickCreatePopoverProps) { const shownName = useCollectionNames(); + const selected = calendars.find((c) => c.id === draft.calendarId); const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); @@ -198,20 +200,17 @@ export function QuickCreatePopover({
c.id === draft.calendarId)?.color || "#3b82f6") }} + style={{ backgroundColor: paint(selected?.color || "#3b82f6") }} aria-hidden="true" /> - + />
)}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 8e2391ead..f754dbdb0 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -32,9 +32,11 @@ interface TaskListViewProps { * The user's own calendars of the active login come first: a colleague's * shared list or another login's is never picked over them. */ -export function defaultTaskList(calendars: Calendar[]): Calendar | undefined { +export function defaultTaskList(calendars: Calendar[], activeAccountId?: string | null): Calendar | undefined { const pick = (list: Calendar[]) => list.find((c) => c.isTasksOnly) ?? list.find((c) => c.isDefault) ?? list[0]; - return pick(calendars.filter((c) => !c.isShared && !c.localAccountId)) ?? pick(calendars); + // With several accounts every calendar names its login: the one on screen counts as the user's own (pim-p2-4). + const own = (c: Calendar) => !c.isShared && (!c.localAccountId || (!!activeAccountId && c.localAccountId === activeAccountId)); + return pick(calendars.filter(own)) ?? pick(calendars); } /** diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 3f85531f8..eab0f174f 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -15,8 +15,10 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { canCreateEventsIn } from "@/lib/calendar-editability"; -import { useCollectionNames } from "@/hooks/use-collection-names"; +import { taskTargets } from "@/lib/calendar-targets"; +import { CalendarSelect } from "@/components/calendar/calendar-select"; +import { useAccountStore } from "@/stores/account-store"; +import { useTaskStore } from "@/stores/task-store"; import { defaultTaskList } from "@/components/calendar/task-list-view"; interface TaskModalProps { @@ -62,9 +64,9 @@ export function TaskModal({ isMobile = false, isSubscriptionCalendar, }: TaskModalProps) { - const shownName = useCollectionNames(); const t = useTranslations("calendar"); const tUx = useTranslations("ux_pim_calendar"); + const tP2 = useTranslations("ux_p2_pim_cal"); const tCommon = useTranslations("common"); // The header's close goes to its start when the window's own close is at its end. const [header, setHeader] = useState(null); @@ -75,11 +77,17 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - // The calendars a task can go into: not read-only ones (Birthdays, - // feeds), the same rule as the event editor. - const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); - // A new task goes to the first task-only list (pim-8). - const defaultCalendarId = defaultTaskList(writableCalendars)?.id ?? calendars[0]?.id ?? ""; + // The lists a task can go into: task lists only, never a calendar of + // events or a read-only one (pim-p2-13). An account without one keeps + // its default calendar, where its tasks already are. + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const tasks = useTaskStore((s) => s.tasks); + const taskCalendarId = task ? Object.keys(task.calendarIds)[0] : undefined; + const writableCalendars = useMemo(() => taskTargets(calendars, isSubscriptionCalendar, { + holdingTasks: tasks.flatMap((x) => Object.keys(x.calendarIds ?? {})), + }), [calendars, isSubscriptionCalendar, tasks]); + // A new task goes to the first task-only list of the account on screen (pim-8). + const defaultCalendarId = defaultTaskList(writableCalendars, activeLocalAccountId)?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); const [description, setDescription] = useState(task?.description ?? ""); @@ -89,14 +97,11 @@ export function TaskModal({ const [priority, setPriority] = useState(priorityToLevel(task?.priority ?? 0)); const [progress, setProgress] = useState(task?.progress ?? "needs-action"); const [calendarId, setCalendarId] = useState(() => { - if (task) { - const ids = Object.keys(task.calendarIds); - return ids[0] ?? defaultCalendarId; - } + if (task) return taskCalendarId ?? defaultCalendarId; return defaultCalendarId; }); // A task already in a read-only calendar keeps it listed. - const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); + const calendarOptions = calendars.filter((c) => c.id === calendarId || c.id === taskCalendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -346,18 +351,16 @@ export function TaskModal({ {calendarOptions.length > 1 && (
- + />
)} diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 20bdd1594..f5ce9c02c 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useTranslations } from "next-intl"; import { Book, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,8 @@ import { Dialog } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { AddressBook } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { groupCalendarsByAccount } from "@/lib/calendar-targets"; +import { useAccountStore } from "@/stores/account-store"; interface AddressBookPickerDialogProps { isOpen: boolean; @@ -26,6 +28,10 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current const t = useTranslations("contacts"); const [selected, setSelected] = useState(null); const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null; + // Under each account's address with several signed in (pim-p2-3). + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + const groups = groupCalendarsByAccount(addressBooks, accounts, activeAccountId); return (
- {addressBooks.map((book) => { + {groups.map((group) => ( + + {group.label && ( +
{group.label}
+ )} + {group.calendars.map((book) => { const isCurrent = book.id === currentId; const isSelected = selected === book.id; return ( @@ -74,6 +85,8 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current ); })} +
+ ))}
); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 5fd5b28c9..e02b79c0e 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -15,6 +15,7 @@ import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, t import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountGroupedOptions } from "@/components/calendar/calendar-select"; // Each row of a list field keeps the `key` of the card's entry it was read // from, so Save can send an entry the user didn't touch back unchanged. @@ -908,12 +909,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-hidden" > - {addressBooks.map((book) => ( - - ))} + {/* Under each account's address with several (pim-p2-3). A shared book says whose it is, unless its name already does. */} + (book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))} + /> )} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 9886cf576..bb63eb8b7 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -15,6 +15,7 @@ import type { AddressBook, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { isOfflineNow } from "@/lib/offline/state"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountGroupedOptions } from "@/components/calendar/calendar-select"; interface ContactImportDialogProps { existingContacts: ContactCard[]; @@ -354,11 +355,10 @@ export function ContactImportDialog({ onChange={(e) => setBookId(e.target.value)} className="flex-1 min-w-0 text-sm bg-transparent border border-input rounded-md px-2.5 py-1.5 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors" > - {addressBooks.map((book) => ( - - ))} + (book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))} + /> )} diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx index 33c632ab4..7b8a25c5b 100644 --- a/components/settings/__tests__/calendar-management-settings.test.tsx +++ b/components/settings/__tests__/calendar-management-settings.test.tsx @@ -161,3 +161,29 @@ describe('CalendarManagementSettings row actions', () => { expect(screen.getByRole('button', { name: 'share_open' })).toBeInTheDocument(); }); }); + +// pim-p2-3: with two accounts signed in, both accounts' calendars were one +// list with two rows reading "Calendar · Default". +describe('CalendarManagementSettings with two accounts', () => { + it("lists each account's calendars under its address", async () => { + const { useAccountStore } = await import('@/stores/account-store'); + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'a@example.org', username: 'a@example.org', displayName: 'Sam', label: 'Sam' }, + { id: 'login-b', email: 'b@example.org', username: 'b@example.org', displayName: 'Sam', label: 'Sam' }, + ] as never, + activeAccountId: 'login-a', + }); + useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never); + const a = { ...appleColored, id: 'login-a::cal', name: 'Home', localAccountId: 'login-a' } as Calendar; + const b = { ...appleColored, id: 'login-b::cal', name: 'Office', localAccountId: 'login-b' } as Calendar; + useCalendarStore.setState({ calendars: [b, a], icalSubscriptions: [], updateCalendar: vi.fn() } as never); + + render(); + + const groups = screen.getAllByTestId('calendar-settings-group'); + expect(groups.map((g) => g.querySelector('[data-testid="calendar-settings-account"]')?.textContent)).toEqual(['a@example.org', 'b@example.org']); + expect(groups.map((g) => Array.from(g.querySelectorAll('[data-testid="calendar-settings-item"]')).map((el) => el.getAttribute('data-calendar-name')))).toEqual([['Home'], ['Office']]); + useAccountStore.setState({ accounts: [], activeAccountId: null }); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 7d7d659c0..c274a7282 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -24,6 +24,7 @@ import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-m import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useAccountStore } from '@/stores/account-store'; +import { groupCalendarsByAccount } from '@/lib/calendar-targets'; import { apiFetch } from '@/lib/browser-navigation'; import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors'; import { IS_LITE } from '@/lib/lite'; @@ -332,6 +333,9 @@ export function CalendarManagementSettings() { // Principal ids belong to one server: only calendars of the account // `client` talks to get their recipients named. const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const localAccounts = useAccountStore((s) => s.accounts); + // Under each account's address when several are signed in (pim-p2-3). + const calendarGroups = groupCalendarsByAccount(listedCalendars, localAccounts, activeLocalAccountId); const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId; const shareLabels = useShareRecipientLabels( client, @@ -651,7 +655,14 @@ export function CalendarManagementSettings() { {renderRowMenu()}
- {listedCalendars.map((cal) => { + {calendarGroups.map((group) => ( +
+ {group.label && ( +

+ {group.label} +

+ )} + {group.calendars.map((cal) => { const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6'; // Up and down step over the neighbours of the same account. const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal)); @@ -819,6 +830,8 @@ export function CalendarManagementSettings() {
); })} +
+ ))} {isCreating ? ( ): string { + return calendar.localAccountId ?? ''; +} + +/** + * Calendars a new event can go into: they show in the grid (not a task + * list), are writable and not a feed. `keepId` stays listed whatever it is, + * so an event that already sits in a task list keeps its one option. + */ +export function eventTargets( + calendars: Calendar[], + isSubscriptionCalendar?: (id: string) => boolean, + keepId?: string, +): Calendar[] { + return calendars.filter((c) => c.id === keepId || (!c.isTasksOnly && !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar))); +} + +/** + * Calendars a task can go into: task lists. A task list is a calendar that + * holds only tasks, or one that holds tasks already (`holdingTasks`). An + * account with neither keeps its default calendar, which is where both + * Stalwarts put a task when nothing else is chosen; without it that account + * couldn't take a task at all. + */ +export function taskTargets( + calendars: Calendar[], + isSubscriptionCalendar?: (id: string) => boolean, + options: { keepId?: string; holdingTasks?: Iterable } = {}, +): Calendar[] { + const holding = new Set(options.holdingTasks ?? []); + const writable = calendars.filter((c) => !c.isCompany && canCreateEventsIn(c, isSubscriptionCalendar)); + const lists = writable.filter((c) => c.isTasksOnly || holding.has(c.id)); + const covered = new Set(lists.map(calendarAccountKey)); + const fallbacks: Calendar[] = []; + for (const cal of writable) { + const key = calendarAccountKey(cal); + if (covered.has(key) || cal.isShared) continue; + const own = writable.filter((c) => calendarAccountKey(c) === key && !c.isShared); + const pick = own.find((c) => c.isDefault) ?? own[0]; + if (pick) fallbacks.push(pick); + covered.add(key); + } + const chosen = new Set([...lists, ...fallbacks].map((c) => c.id)); + return calendars.filter((c) => chosen.has(c.id) || c.id === options.keepId); +} + +export interface CalendarGroup = Calendar> { + /** The local account id, '' for calendars of the one signed-in account. */ + key: string; + /** The account's address; null when there's only one group. */ + label: string | null; + calendars: T[]; +} + +/** + * Calendars by account: the active one first, then the others in the order + * they were added, each named by its address. One group without a label + * when only one account is there. + */ +export function groupCalendarsByAccount>( + calendars: T[], + accounts: TargetAccount[], + activeAccountId: string | null | undefined, +): CalendarGroup[] { + const byKey = new Map(); + for (const cal of calendars) { + const key = calendarAccountKey(cal); + byKey.set(key, [...(byKey.get(key) ?? []), cal]); + } + if (byKey.size <= 1) { + const [key = ''] = byKey.keys(); + return calendars.length > 0 ? [{ key, label: null, calendars }] : []; + } + const order = [ + ...(activeAccountId ? [activeAccountId] : []), + ...accounts.map((a) => a.id).filter((id) => id !== activeAccountId), + ...byKey.keys(), + ]; + const groups: CalendarGroup[] = []; + for (const key of order) { + const list = byKey.get(key); + if (!list) continue; + byKey.delete(key); + const account = accounts.find((a) => a.id === key); + groups.push({ key, label: key ? accountAddress(account) || key : null, calendars: list }); + } + return groups; +} + +/** Whether a calendar belongs to the account on screen. With one account, all do. */ +function ofAccount(cal: Calendar, activeAccountId: string | null | undefined): boolean { + return !cal.localAccountId || cal.localAccountId === activeAccountId; +} + +/** + * The calendar a new event preselects: the one last picked for the account + * on screen, else that account's default calendar, else its first own one. + * Another account's calendar only when the active one has none. + */ +export function preferredTarget( + targets: Calendar[], + activeAccountId: string | null | undefined, + lastPicked?: Record, +): Calendar | undefined { + const accountKey = targets.some((c) => c.localAccountId) ? (activeAccountId ?? '') : ''; + const remembered = lastPicked?.[accountKey]; + const mine = targets.filter((c) => ofAccount(c, activeAccountId)); + return (remembered ? mine.find((c) => c.id === remembered) : undefined) + ?? mine.find((c) => c.isDefault && !c.isShared) + ?? mine.find((c) => !c.isShared) + ?? mine[0] + ?? targets.find((c) => c.isDefault) + ?? targets[0]; +} + +/** The key `preferredTarget` remembers a pick under. */ +export function rememberKey(calendar: Pick): string { + return calendarAccountKey(calendar); +} + +/** + * Each account's default calendar in a colour of its own (pim-p2-3). Both + * Stalwarts give every new account's default calendar the same blue, so two + * accounts' meetings looked alike. Only for display: the server keeps its + * colour, and a colour the user set on the server that differs stays. + */ +export function distinctAccountDefaultColors(calendars: T[]): T[] { + const accounts = new Set(calendars.map(calendarAccountKey)); + if (accounts.size < 2) return calendars; + const norm = (c: string | null | undefined) => (c ?? CALENDAR_COLORS[0]).toLowerCase(); + const used = new Set(calendars.map((c) => norm(c.color))); + const taken = new Set(); + const changed = new Map(); + for (const cal of calendars) { + if (!cal.isDefault || cal.isShared || !cal.localAccountId) continue; + const color = norm(cal.color); + if (!taken.has(color)) { + taken.add(color); + continue; + } + const next = CALENDAR_COLORS.find((c) => !used.has(c.toLowerCase())) + ?? CALENDAR_COLORS.find((c) => !taken.has(c.toLowerCase())) + ?? CALENDAR_COLORS[0]; + used.add(next.toLowerCase()); + taken.add(next.toLowerCase()); + changed.set(cal.id, next); + } + if (changed.size === 0) return calendars; + return calendars.map((c) => (changed.has(c.id) ? { ...c, color: changed.get(c.id)! } : c)); +} + +/** + * Where "Copy to " puts an event (pim-p2-16): for every other + * signed-in account with a calendar for events, that account's own default + * one (or its first). An event moves only within its own account, since the + * update goes to its own server, so the other account gets a copy. + */ +export function copyTargetsFor( + event: Pick, + targets: Calendar[], + accounts: TargetAccount[], +): Array<{ account: TargetAccount; calendar: Calendar }> { + const from = event.localAccountId; + if (!from) return []; + const result: Array<{ account: TargetAccount; calendar: Calendar }> = []; + for (const account of accounts) { + if (account.id === from) continue; + const own = targets.filter((c) => c.localAccountId === account.id && !c.isShared); + const calendar = own.find((c) => c.isDefault) ?? own[0]; + if (calendar) result.push({ account, calendar }); + } + return result; +} + +/** + * The new event for a copy in another account: the same time, place, text + * and repeat rule, under a new UID. Not its participants: the copy is the + * user's own entry, and creating it must not invite anyone or answer for + * the other account. Skipped occurrences stay skipped; changed ones don't + * carry over, since they hold the participants' answers. + */ +export function buildCopyToCalendarData(event: CalendarEvent, calendarId: string): Partial { + const data: Partial = { + uid: generateUUID(), + title: event.title, + description: event.description, + start: event.start, + duration: event.duration, + timeZone: event.timeZone, + showWithoutTime: event.showWithoutTime, + calendarIds: { [calendarId]: true }, + status: event.status === 'cancelled' ? 'cancelled' : 'confirmed', + freeBusyStatus: event.freeBusyStatus, + privacy: event.privacy, + }; + if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType; + if (event.locations) data.locations = structuredClone(event.locations); + if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); + if (event.alerts) data.alerts = structuredClone(event.alerts); + if (event.recurrenceRules?.length) { + data.recurrenceRules = structuredClone(event.recurrenceRules); + if (event.excludedRecurrenceRules?.length) data.excludedRecurrenceRules = structuredClone(event.excludedRecurrenceRules); + const skipped = Object.entries(event.recurrenceOverrides ?? {}).filter(([, patch]) => (patch as { excluded?: boolean } | null)?.excluded === true); + if (skipped.length) data.recurrenceOverrides = Object.fromEntries(skipped.map(([id]) => [id, { excluded: true }])) as CalendarEvent['recurrenceOverrides']; + } + return sanitizeOutgoingCalendarEventData(data); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..c292bd340 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "مشارَك من {name}:", "freebusy_description": "من تشارك معه يرى متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات." }, + "ux_p2_pim_cal": { + "task_list_label": "القائمة", + "task_list_not_in_calendar": "{name} (قائمة مهام، لا تظهر في التقويم)", + "copy_to_account": "نسخ إلى {address}", + "copied_to_account": "تم النسخ إلى {address}", + "delete_original": "حذف الأصل" + }, "calendar_offline": { "split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال." }, diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..75155c3d9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Compartit per {name}:", "freebusy_description": "Qui rebi la compartició veu quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir." }, + "ux_p2_pim_cal": { + "task_list_label": "Llista", + "task_list_not_in_calendar": "{name} (llista de tasques, no es mostra al calendari)", + "copy_to_account": "Copia a {address}", + "copied_to_account": "S'ha copiat a {address}", + "delete_original": "Suprimeix l'original" + }, "calendar_offline": { "split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat." }, diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..1ca5b0b98 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Sdílí {name}:", "freebusy_description": "Ten, s kým sdílíte, vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy." }, + "ux_p2_pim_cal": { + "task_list_label": "Seznam", + "task_list_not_in_calendar": "{name} (seznam úkolů, v kalendáři se nezobrazuje)", + "copy_to_account": "Kopírovat do {address}", + "copied_to_account": "Zkopírováno do {address}", + "delete_original": "Smazat originál" + }, "calendar_offline": { "split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení." }, diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..b475e94b9 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Delt af {name}:", "freebusy_description": "Dem, du deler med, ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (opgaveliste, vises ikke i kalenderen)", + "copy_to_account": "Kopiér til {address}", + "copied_to_account": "Kopieret til {address}", + "delete_original": "Slet originalen" + }, "calendar_offline": { "split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online." }, diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..34b925621 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Freigegeben von {name}:", "freebusy_description": "Wer die Freigabe erhält, sieht, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (Aufgabenliste, nicht im Kalender sichtbar)", + "copy_to_account": "In {address} kopieren", + "copied_to_account": "In {address} kopiert", + "delete_original": "Original löschen" + }, "calendar_offline": { "split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind." }, diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..be8994d30 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Shared by {name}:", "freebusy_description": "Whoever you share with sees when you’re busy, not what your events are. Suggest times uses it." }, + "ux_p2_pim_cal": { + "task_list_label": "List", + "task_list_not_in_calendar": "{name} (task list, not shown in the calendar)", + "copy_to_account": "Copy to {address}", + "copied_to_account": "Copied to {address}", + "delete_original": "Delete original" + }, "calendar_offline": { "split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online." }, diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..394d9c365 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Compartido por {name}:", "freebusy_description": "Quien reciba lo compartido ve cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista de tareas, no se muestra en el calendario)", + "copy_to_account": "Copiar a {address}", + "copied_to_account": "Copiado a {address}", + "delete_original": "Eliminar el original" + }, "calendar_offline": { "split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión." }, diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..c3fdc5b00 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Jagas {name}:", "freebusy_description": "Kellega jagad, näeb, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“." }, + "ux_p2_pim_cal": { + "task_list_label": "Loend", + "task_list_not_in_calendar": "{name} (ülesannete loend, kalendris ei näidata)", + "copy_to_account": "Kopeeri kontole {address}", + "copied_to_account": "Kopeeritud kontole {address}", + "delete_original": "Kustuta originaal" + }, "calendar_offline": { "split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus." }, diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..16fc7870f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "به اشتراک گذاشته‌شده توسط {name}:", "freebusy_description": "کسی که با او به اشتراک می‌گذارید می‌بیند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده می‌کند." }, + "ux_p2_pim_cal": { + "task_list_label": "فهرست", + "task_list_not_in_calendar": "{name} (فهرست کارها، در تقویم نشان داده نمی‌شود)", + "copy_to_account": "رونوشت در {address}", + "copied_to_account": "در {address} رونوشت شد", + "delete_original": "حذف نسخهٔ اصلی" + }, "calendar_offline": { "split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید." }, diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..0f1592299 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Partagé par {name} :", "freebusy_description": "Les personnes avec qui vous partagez voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (liste de tâches, non affichée dans le calendrier)", + "copy_to_account": "Copier vers {address}", + "copied_to_account": "Copié vers {address}", + "delete_original": "Supprimer l'original" + }, "calendar_offline": { "split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne." }, diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..6fa171bbc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5099,6 +5099,13 @@ "caldav_shared_by": "משותף על ידי {name}:", "freebusy_description": "מי שאתה משתף איתו רואה מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה." }, + "ux_p2_pim_cal": { + "task_list_label": "רשימה", + "task_list_not_in_calendar": "{name} (רשימת משימות, לא מוצגת בלוח השנה)", + "copy_to_account": "העתקה אל {address}", + "copied_to_account": "הועתק אל {address}", + "delete_original": "מחיקת המקור" + }, "calendar_offline": { "split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור." }, diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..8dc46b9d4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Megosztotta: {name}", "freebusy_description": "Akivel megoszt, látja, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (feladatlista, a naptárban nem látszik)", + "copy_to_account": "Másolás ide: {address}", + "copied_to_account": "Átmásolva ide: {address}", + "delete_original": "Eredeti törlése" + }, "calendar_offline": { "split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz." }, diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..d5e753b8b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Condiviso da {name}:", "freebusy_description": "Chi riceve la condivisione vede quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa." }, + "ux_p2_pim_cal": { + "task_list_label": "Elenco", + "task_list_not_in_calendar": "{name} (elenco attività, non visibile nel calendario)", + "copy_to_account": "Copia in {address}", + "copied_to_account": "Copiato in {address}", + "delete_original": "Elimina l'originale" + }, "calendar_offline": { "split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online." }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..5606b1815 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "{name} が共有:", "freebusy_description": "共有した相手には予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。" }, + "ux_p2_pim_cal": { + "task_list_label": "リスト", + "task_list_not_in_calendar": "{name}(タスクリスト、カレンダーには表示されません)", + "copy_to_account": "{address} にコピー", + "copied_to_account": "{address} にコピーしました", + "delete_original": "元の予定を削除" + }, "calendar_offline": { "split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..f9eaaa275 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "{name} 님이 공유:", "freebusy_description": "공유받은 사람은 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다." }, + "ux_p2_pim_cal": { + "task_list_label": "목록", + "task_list_not_in_calendar": "{name} (할 일 목록, 캘린더에 표시되지 않음)", + "copy_to_account": "{address}(으)로 복사", + "copied_to_account": "{address}(으)로 복사됨", + "delete_original": "원본 삭제" + }, "calendar_offline": { "split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요." }, diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..0d67fba90 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Kopīgoja {name}:", "freebusy_description": "Tas, ar kuru kopīgojat, redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”." }, + "ux_p2_pim_cal": { + "task_list_label": "Saraksts", + "task_list_not_in_calendar": "{name} (uzdevumu saraksts, kalendārā netiek rādīts)", + "copy_to_account": "Kopēt uz {address}", + "copied_to_account": "Nokopēts uz {address}", + "delete_original": "Dzēst oriģinālu" + }, "calendar_offline": { "split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē." }, diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..ca0a9bb8c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "{name} хуваалцсан:", "freebusy_description": "Таны хуваалцсан хүн таныг хэзээ завгүйг хардаг ч үйл явдлыг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг." }, + "ux_p2_pim_cal": { + "task_list_label": "Жагсаалт", + "task_list_not_in_calendar": "{name} (ажлын жагсаалт, хуанлид харагдахгүй)", + "copy_to_account": "{address} руу хуулах", + "copied_to_account": "{address} руу хуулсан", + "delete_original": "Эх хувийг устгах" + }, "calendar_offline": { "split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу." }, diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..ac96cf9ae 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Delt av {name}:", "freebusy_description": "De du deler med, ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (oppgaveliste, vises ikke i kalenderen)", + "copy_to_account": "Kopier til {address}", + "copied_to_account": "Kopiert til {address}", + "delete_original": "Slett originalen" + }, "calendar_offline": { "split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet." }, diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..136446aa7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Gedeeld door {name}:", "freebusy_description": "Wie je deelt, ziet wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit." }, + "ux_p2_pim_cal": { + "task_list_label": "Lijst", + "task_list_not_in_calendar": "{name} (takenlijst, niet zichtbaar in de agenda)", + "copy_to_account": "Kopiëren naar {address}", + "copied_to_account": "Gekopieerd naar {address}", + "delete_original": "Origineel verwijderen" + }, "calendar_offline": { "split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent." }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..9b11143a8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Udostępnia {name}:", "freebusy_description": "Osoby, którym udostępniasz, widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista zadań, niewidoczna w kalendarzu)", + "copy_to_account": "Kopiuj do {address}", + "copied_to_account": "Skopiowano do {address}", + "delete_original": "Usuń oryginał" + }, "calendar_offline": { "split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online." }, diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..8c5c2731b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Compartilhado por {name}:", "freebusy_description": "Quem recebe o compartilhamento vê quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto." }, + "ux_p2_pim_cal": { + "task_list_label": "Lista", + "task_list_not_in_calendar": "{name} (lista de tarefas, não aparece no calendário)", + "copy_to_account": "Copiar para {address}", + "copied_to_account": "Copiado para {address}", + "delete_original": "Eliminar o original" + }, "calendar_offline": { "split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online." }, diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..d3f91819a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Partajat de {name}:", "freebusy_description": "Cei cu care partajați văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta." }, + "ux_p2_pim_cal": { + "task_list_label": "Listă", + "task_list_not_in_calendar": "{name} (listă de sarcini, nu apare în calendar)", + "copy_to_account": "Copiază în {address}", + "copied_to_account": "Copiat în {address}", + "delete_original": "Șterge originalul" + }, "calendar_offline": { "split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online." }, diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..ccfa97d4b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Поделился {name}:", "freebusy_description": "Те, с кем вы поделились, видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»." }, + "ux_p2_pim_cal": { + "task_list_label": "Список", + "task_list_not_in_calendar": "{name} (список задач, не отображается в календаре)", + "copy_to_account": "Копировать в {address}", + "copied_to_account": "Скопировано в {address}", + "delete_original": "Удалить оригинал" + }, "calendar_offline": { "split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети." }, diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..f3cc075e5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Zdieľa {name}:", "freebusy_description": "Ten, s kým zdieľate, vidí, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy." }, + "ux_p2_pim_cal": { + "task_list_label": "Zoznam", + "task_list_not_in_calendar": "{name} (zoznam úloh, v kalendári sa nezobrazuje)", + "copy_to_account": "Kopírovať do {address}", + "copied_to_account": "Skopírované do {address}", + "delete_original": "Odstrániť originál" + }, "calendar_offline": { "split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online." }, diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..283d08bf7 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4617,6 +4617,13 @@ "caldav_shared_by": "แชร์โดย {name}:", "freebusy_description": "ผู้ที่คุณแชร์ด้วยจะเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้" }, + "ux_p2_pim_cal": { + "task_list_label": "รายการ", + "task_list_not_in_calendar": "{name} (รายการงาน ไม่แสดงในปฏิทิน)", + "copy_to_account": "คัดลอกไปที่ {address}", + "copied_to_account": "คัดลอกไปที่ {address} แล้ว", + "delete_original": "ลบต้นฉบับ" + }, "calendar_offline": { "split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์" }, diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..670c1b18a 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Paylaşan: {name}", "freebusy_description": "Paylaştığınız kişiler ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır." }, + "ux_p2_pim_cal": { + "task_list_label": "Liste", + "task_list_not_in_calendar": "{name} (görev listesi, takvimde gösterilmez)", + "copy_to_account": "{address} hesabına kopyala", + "copied_to_account": "{address} hesabına kopyalandı", + "delete_original": "Orijinali sil" + }, "calendar_offline": { "split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin." }, diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..9bb1e5f17 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "Поділився {name}:", "freebusy_description": "Ті, з ким ви поділилися, бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»." }, + "ux_p2_pim_cal": { + "task_list_label": "Список", + "task_list_not_in_calendar": "{name} (список завдань, не показується в календарі)", + "copy_to_account": "Копіювати до {address}", + "copied_to_account": "Скопійовано до {address}", + "delete_original": "Видалити оригінал" + }, "calendar_offline": { "split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі." }, diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..c65d3ad2e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5217,6 +5217,13 @@ "caldav_shared_by": "Được chia sẻ bởi {name}:", "freebusy_description": "Người được chia sẻ thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này." }, + "ux_p2_pim_cal": { + "task_list_label": "Danh sách", + "task_list_not_in_calendar": "{name} (danh sách việc cần làm, không hiện trong lịch)", + "copy_to_account": "Sao chép sang {address}", + "copied_to_account": "Đã sao chép sang {address}", + "delete_original": "Xóa bản gốc" + }, "calendar_offline": { "split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại." }, diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..23f97a00e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "{name} 分享:", "freebusy_description": "分享對象能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。" }, + "ux_p2_pim_cal": { + "task_list_label": "清單", + "task_list_not_in_calendar": "{name}(工作清單,不會顯示在行事曆中)", + "copy_to_account": "複製到 {address}", + "copied_to_account": "已複製到 {address}", + "delete_original": "刪除原始活動" + }, "calendar_offline": { "split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。" }, diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..c90ff4296 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5205,6 +5205,13 @@ "caldav_shared_by": "{name} 共享:", "freebusy_description": "共享对象能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。" }, + "ux_p2_pim_cal": { + "task_list_label": "列表", + "task_list_not_in_calendar": "{name}(任务列表,不在日历中显示)", + "copy_to_account": "复制到 {address}", + "copied_to_account": "已复制到 {address}", + "delete_original": "删除原日程" + }, "calendar_offline": { "split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。" }, diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7b0f8dbca..d53cad78d 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -928,6 +928,12 @@ interface CalendarStore { */ hiddenTaskListIds: string[]; toggleTaskListVisibility: (calendarId: string) => void; + /** + * The calendar a new event last went into, per local account ('' with one + * account): new events, quick create and imports preselect it (pim-p2-4). + */ + lastEventCalendarIds: Record; + rememberEventCalendar: (calendar: { id: string; localAccountId?: string }) => void; setSelectedEventId: (id: string | null) => void; clearState: () => void; @@ -960,6 +966,7 @@ const initialState = { calendarSelectionChosen: false, knownCalendarIds: null as string[] | null, hiddenTaskListIds: [] as string[], + lastEventCalendarIds: {} as Record, accountClients: null as CalendarAccountClient[] | null, accountClientsActiveId: null as string | null, selectedEventId: null as string | null, @@ -1809,6 +1816,12 @@ export const useCalendarStore = create()( })); }, + rememberEventCalendar: (calendar) => { + const key = calendar.localAccountId ?? ''; + if (get().lastEventCalendarIds[key] === calendar.id) return; + set((state) => ({ lastEventCalendarIds: { ...state.lastEventCalendarIds, [key]: calendar.id } })); + }, + setSelectedEventId: (id) => set({ selectedEventId: id }), // iCal subscriptions @@ -2185,11 +2198,14 @@ export const useCalendarStore = create()( // here would lose them from localStorage on every switch. const preservedSubs = get().icalSubscriptions; const preservedTombstones = get().deletedSubscriptionIds; + // Keyed by account, so a switch keeps each account's last pick. + const preservedPicks = get().lastEventCalendarIds; set({ ...initialState, selectedDate: displayNow(), icalSubscriptions: preservedSubs, deletedSubscriptionIds: preservedTombstones, + lastEventCalendarIds: preservedPicks, }); import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => { useCalendarNotificationStore.getState().clearAll(); @@ -2208,6 +2224,9 @@ export const useCalendarStore = create()( ...mergedState, selectedDate: displayNow(), viewMode: getSafeCalendarViewMode(mergedState.viewMode), + lastEventCalendarIds: mergedState.lastEventCalendarIds && typeof mergedState.lastEventCalendarIds === 'object' && !Array.isArray(mergedState.lastEventCalendarIds) + ? mergedState.lastEventCalendarIds + : {}, }; }, partialize: (state) => ({ @@ -2215,6 +2234,7 @@ export const useCalendarStore = create()( calendarSelectionChosen: state.calendarSelectionChosen, knownCalendarIds: state.knownCalendarIds, hiddenTaskListIds: state.hiddenTaskListIds, + lastEventCalendarIds: state.lastEventCalendarIds, viewMode: state.viewMode, icalSubscriptions: state.icalSubscriptions, deletedSubscriptionIds: state.deletedSubscriptionIds, From cf7dda5fd1d116d1aa151be0006bce2f8e3cf235 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:50:57 +0100 Subject: [PATCH 06/29] fix: list every signed-in account's calendars in Settings, not only the one on screen --- .../calendar-management-settings.test.tsx | 32 +++++++++++++++++++ .../settings/calendar-management-settings.tsx | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx index 7b8a25c5b..7cf86f45d 100644 --- a/components/settings/__tests__/calendar-management-settings.test.tsx +++ b/components/settings/__tests__/calendar-management-settings.test.tsx @@ -187,3 +187,35 @@ describe('CalendarManagementSettings with two accounts', () => { useAccountStore.setState({ accounts: [], activeAccountId: null }); }); }); + +// pim-p2-3: opened straight away (no visit to Calendar first), Settings +// loaded only the account on screen. +describe('CalendarManagementSettings opened with two accounts signed in', () => { + it("loads and lists both accounts' calendars", async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const fake = (name: string) => ({ + supportsCalendars: () => true, + getAccountId: () => 'acct', + getAllCalendarsWithFailures: vi.fn(async () => ({ calendars: [{ ...appleColored, id: 'cal', name }], failedAccountIds: [] })), + }); + const clients: Record = { 'login-a': fake('Home'), 'login-b': fake('Office') }; + useAccountStore.setState({ + accounts: [ + { id: 'login-a', email: 'a@example.org', username: 'a@example.org', displayName: 'Sam', label: 'Sam', isConnected: true }, + { id: 'login-b', email: 'b@example.org', username: 'b@example.org', displayName: 'Sam', label: 'Sam', isConnected: true }, + ] as never, + activeAccountId: 'login-a', + }); + useAuthStore.setState({ + client: clients['login-a'], serverUrl: null, username: null, activeAccountId: 'login-a', + getClientForAccount: ((id: string) => clients[id]) as never, + } as never); + useCalendarStore.setState({ calendars: [], icalSubscriptions: [], updateCalendar: vi.fn() } as never); + + render(); + + await waitFor(() => expect(screen.getAllByTestId('calendar-settings-account').map((el) => el.textContent)).toEqual(['a@example.org', 'b@example.org'])); + useAccountStore.setState({ accounts: [], activeAccountId: null }); + useCalendarStore.getState().setAccountClients(null, null); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index c274a7282..e0b068569 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -25,6 +25,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useAccountStore } from '@/stores/account-store'; import { groupCalendarsByAccount } from '@/lib/calendar-targets'; +import { useProMultiAccountCalendars } from '@/hooks/use-pro-multi-account-calendars'; import { apiFetch } from '@/lib/browser-navigation'; import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors'; import { IS_LITE } from '@/lib/lite'; @@ -342,12 +343,14 @@ export function CalendarManagementSettings() { calendars.some(cal => !cal.isShared && Object.keys(cal.shareWith ?? {}).length > 0 && resolvesShares(cal)), ); - // Load calendars if not yet loaded + // With several accounts signed in, every account's calendars, as in + // Calendar (pim-p2-3); else load them if not yet loaded. + const { enabled: allAccounts } = useProMultiAccountCalendars(null, null); useEffect(() => { - if (client && calendars.length === 0) { + if (client && calendars.length === 0 && !allAccounts) { fetchCalendars(client); } - }, [client, calendars.length, fetchCalendars]); + }, [client, calendars.length, fetchCalendars, allAccounts]); useEffect(() => { // CalDAV discovery runs through /api/caldav/discover, absent in the static build. From cf844ff4d7368713d3842d8c1317c1665ce90bb6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:51:08 +0100 Subject: [PATCH 07/29] fix: keep an event's title in sight while its block runs above the visible hours, as an overnight event's morning part --- .../event-block-title-ux-p2-pim.test.tsx | 37 +++++++++++++++++++ components/calendar/event-card.tsx | 10 ++++- hooks/use-display-hours.ts | 8 +++- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fce16b53c --- /dev/null +++ b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EventCard } from '../event-card'; +import { useDisplayHours } from '@/hooks/use-display-hours'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the +// top of the day, scrolled out of view, so the week showed a block with no +// title. The title line now sticks under the grid's header while the block is +// in sight. A block that hid its overflow was a scroller of its own, and a +// sticky line inside one sticks to the block, not to the grid. + +const train = { + id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00', + duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null, +} as unknown as CalendarEvent; + +describe('the title of a long block stays in sight', () => { + it('sticks the title line under the header and clips the block without making it a scroller', () => { + render(); + const card = screen.getByRole('button', { name: /Night train to Vienna/ }); + expect(card.className).toContain('overflow-clip'); + expect(card.className).not.toContain('overflow-hidden'); + const line = card.querySelector('[data-event-title-line]') as HTMLElement; + expect(line.className).toContain('sticky'); + expect(line.className).toContain('top-[var(--grid-header-height,0px)]'); + expect(line.textContent).toContain('Night train to Vienna'); + }); + + it('tells the grid how tall its header is, for the sticky title', () => { + const pane = document.createElement('div'); + const header = document.createElement('div'); + Object.defineProperty(header, 'offsetHeight', { value: 96 }); + renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 })); + expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px'); + }); +}); diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 4a31e76e9..8f9732155 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM data-continues-after={continuesAfter || undefined} className={cn( // A block box: an inline one hangs its line's descent below the - // grid's last hour and makes the filled pane scroll. - "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", + // grid's last hour and makes the filled pane scroll. Clipped, not + // hidden: hidden makes the block a scroller and its title can't stick. + "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip", "hover:opacity-90 transition-opacity cursor-pointer", // Cut at the edge of the visible hours: a square edge and an arrow // say the event runs on past it. @@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {/* Shorter blocks have no room for a second line at the 60px hour, so their start time follows the title on the same line, when the block is wide enough to keep the title readable. */} + {/* The title stays in sight under the grid's header while the block + is: the morning part of a night train started at midnight, scrolled + out of view, and showed as a block with no name (pim-p2-19). */} +
{event.title || t("events.no_title")} @@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)} +
{visibleCalendarName && (
{visibleCalendarName}
)} diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts index 68a64e34c..39821c87f 100644 --- a/hooks/use-display-hours.ts +++ b/hooks/use-display-hours.ts @@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt useLayoutEffect(() => { const root = scrollRef.current; if (!root) return; - const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0))); + const measure = () => { + const headerHeight = headerRef?.current?.offsetHeight ?? 0; + setAvailable(Math.max(0, root.clientHeight - headerHeight)); + // Where an event's title sticks while its block runs above the visible + // hours: an overnight event's morning part (pim-p2-19). + root.style?.setProperty("--grid-header-height", `${headerHeight}px`); + }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); From 311e98b59c25bab3b70adfb0a1cec254e153307d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:52:25 +0100 Subject: [PATCH 08/29] test: check two accounts' calendar choosers, new-event defaults and Copy to on both Stalwarts --- integration/tests/26-ux-p2-pim-cal.spec.ts | 162 +++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 integration/tests/26-ux-p2-pim-cal.spec.ts diff --git a/integration/tests/26-ux-p2-pim-cal.spec.ts b/integration/tests/26-ux-p2-pim-cal.spec.ts new file mode 100644 index 000000000..cd6f3bdb9 --- /dev/null +++ b/integration/tests/26-ux-p2-pim-cal.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { format } from 'date-fns'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, calendar with two accounts: pim-p2-2 (no task list for + * events), p2-3 (accounts named by address, choosers grouped), p2-4 (new + * events in the account on screen), p2-13 (task editor's "List") and p2-16 + * (Copy to the other account). Against a VM 106 Stalwart, 1.0 and 0.16; + * every UI step is checked on the server. + * + * Accounts: dso-ux-pim (A, has the seeded task-only "To-do") and + * dso-ux-pim-b (B), password IT_DSO_PASSWORD. Items are titled + * "UXP2-cal …" and removed again. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CAL = 'urn:ietf:params:jmap:calendars'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const prefix = `UXP2-cal ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Obj = Record & { id: string; title?: string; calendarIds?: Record; start?: string }; + +async function calendars(c: JmapClient): Promise> { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]); + return r.methodResponses[0][1].list; +} + +async function objects(c: JmapClient, startsWith: string): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', '@type', 'title', 'calendarIds', 'start', 'participants'], + }, 'g'], + ], [CORE, CAL]); + return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(startsWith)); +} + +async function destroyAll(c: JmapClient, startsWith: string): Promise { + const ids = (await objects(c, startsWith)).map((o) => o.id); + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +async function signIn(page: Page, email: string): Promise { + 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"]'); +} + +async function activeEmail(page: Page): Promise { + const id = await accountSwitcher(page).getAttribute('data-active-account-id'); + if (!id) return null; + await accountSwitcher(page).click(); + const email = await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().getAttribute('data-account-email'); + await page.keyboard.press('Escape'); + return email; +} + +/** The label of the optgroup the selected option sits in, and every option's text by group. */ +async function selectState(select: Locator): Promise<{ checkedGroup: string | null; groups: Record }> { + return select.evaluate((el) => { + const select = el as HTMLSelectElement; + const groups: Record = {}; + for (const g of Array.from(select.querySelectorAll('optgroup'))) { + groups[g.label] = Array.from(g.querySelectorAll('option')).map((o) => o.textContent ?? ''); + } + const checked = select.selectedOptions[0]; + return { checkedGroup: checked?.parentElement instanceof HTMLOptGroupElement ? checked.parentElement.label : null, groups }; + }); +} + +test.describe('Two accounts in Calendar (pim-p2-2, p2-3, p2-4, p2-13, p2-16)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + for (const email of [A, B]) { + const c = await JmapClient.connect(email, PASSWORD); + await destroyAll(c, prefix); + } + }); + + test('names accounts by address, starts new events in the account on screen, and copies an event across', async ({ browser }) => { + test.setTimeout(300_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const aHome = (await calendars(a)).find((c) => c.isDefault)!; + const bHome = (await calendars(b)).find((c) => c.isDefault)!; + expect((await calendars(a)).some((c) => c.name === 'To-do'), 'seeded To-do list on A').toBe(true); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, A); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + await accountSwitcher(page).click(); + await page.locator('[data-testid="add-account"]').click(); + await signIn(page, B); + // B is the account on screen from here on. + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + + const today = format(new Date(), 'yyyy-MM-dd'); + await page.goto(`/calendar/week/${today}`); + + // p2-3: each group is headed by its address, the account on screen first. + await expect(page.getByTestId('calendar-account-group').first()).toBeVisible({ timeout: 60000 }); + await expect(page.getByTestId('calendar-account-group')).toHaveText([B, A]); + + // p2-2, p2-4: the editor starts in B's calendar, groups by address, and offers no task list. + await page.getByRole('button', { name: 'Create event' }).first().click(); + const editorSelect = page.locator('select[id$="calendar_select"]').first(); + await expect(editorSelect).toBeVisible({ timeout: 30000 }); + const editor = await selectState(editorSelect); + expect(editor.checkedGroup).toBe(B); + expect(Object.keys(editor.groups)).toEqual([B, A]); + expect(Object.values(editor.groups).flat().some((name) => name.startsWith('To-do'))).toBe(false); + await page.getByLabel('Title', { exact: true }).fill(`${prefix} new`); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => (await objects(b, `${prefix} new`)).map((o) => Object.keys(o.calendarIds ?? {})), { timeout: 30000 }) + .toEqual([[bHome.id]]); + expect(await objects(a, prefix)).toEqual([]); + const made = (await objects(b, `${prefix} new`))[0]; + + // p2-16: Copy to A, then delete the original from the toast. + const card = page.locator(`button[aria-label^="${prefix} new,"]`).first(); + await expect(card).toBeVisible({ timeout: 30000 }); + await card.click({ button: 'right' }); + await page.getByRole('menuitem', { name: `Copy to ${A}` }).click(); + await expect.poll(async () => (await objects(a, `${prefix} new`)).map((o) => [Object.keys(o.calendarIds ?? {}), o.start]), { timeout: 30000 }) + .toEqual([[[aHome.id], made.start]]); + await page.getByRole('button', { name: 'Delete original' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: /Delete/ }).click(); + await expect.poll(async () => (await objects(b, `${prefix} new`)).length, { timeout: 30000 }).toBe(0); + expect((await objects(a, `${prefix} new`)).length).toBe(1); + + // p2-13: the task editor's "List" holds task lists only, grouped by address. + await page.goto('/calendar/tasks'); + await page.getByRole('button', { name: 'New task' }).first().click(); + const list = page.getByLabel('List', { exact: true }); + await expect(list).toBeVisible({ timeout: 30000 }); + const tasks = await selectState(list); + expect(tasks.groups[A]).toContain('To-do'); + expect(Object.values(tasks.groups).flat()).not.toContain('Work'); + await page.keyboard.press('Escape'); + + // p2-3: Settings lists each account's calendars under its address. + await page.goto('/settings/calendar'); + await expect(page.getByTestId('calendar-settings-account')).toHaveText([B, A], { timeout: 60000 }); + await context.close(); + }); +}); From b269d2f2ed5c2184f29064238e999e208ee3f003 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:54:22 +0100 Subject: [PATCH 09/29] fix: show toasts beside the calendar's event and task panels instead of over their title and Save --- app/globals.css | 8 ++++ .../toast-beside-sheet-ux-p2-pim.test.tsx | 42 +++++++++++++++++++ components/calendar/calendar-app.tsx | 4 +- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx diff --git a/app/globals.css b/app/globals.css index 6ecc0f095..b99ba0f7e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -638,6 +638,14 @@ html[data-window-controls] [data-own-window-close] { display: none; } +/* + * A sheet docked at the window's end (the calendar's event and task panels) has its title + * and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19). + */ +body:has([data-end-sheet]) [data-toast-container] { + inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem); +} + /* Toasts that step out of the focus's way go to the top end corner: below the controls there. */ html[data-window-controls] [data-toast-container][data-dodging] { top: calc(var(--titlebar-row-height) + 0.75rem) !important; diff --git a/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx new file mode 100644 index 000000000..148a6a4e5 --- /dev/null +++ b/components/calendar/__tests__/toast-beside-sheet-ux-p2-pim.test.tsx @@ -0,0 +1,42 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +// pim-p2-19: toasts at the top right covered the event sheet's title and +// close button; at the bottom right they covered its Save. They stand beside +// a sheet docked at the window's end now. jsdom has no layout, so the rule is +// checked through the cascade: the stylesheet's own rule, applied in jsdom. + +const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8'); + +function endSheetRule(): string { + const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css); + expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull(); + return match![0]; +} + +describe('toasts beside an end sheet', () => { + it('move the stack past the sheet while one is open', () => { + const style = document.createElement('style'); + style.textContent = endSheetRule(); + document.head.appendChild(style); + const container = document.createElement('div'); + container.setAttribute('data-toast-container', ''); + document.body.appendChild(container); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px'); + + const sheet = document.createElement('div'); + sheet.setAttribute('data-end-sheet', ''); + document.body.appendChild(sheet); + expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)'); + style.remove(); + container.remove(); + sheet.remove(); + }); + + it('marks the calendar event and task panels as end sheets', () => { + const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8'); + expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/); + expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40} +
+
Date: Wed, 4 Nov 2026 13:56:09 +0100 Subject: [PATCH 10/29] 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 11/29] 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 12/29] 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 179d3994bbdb421e9a54cb27658d6c27b1914e8a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:57:46 +0100 Subject: [PATCH 13/29] feat: refresh Calendar, Contacts and Files with F9 in the desktop app, and move through the calendar from Go --- .../calendar-desktop-menu-ux-pim.test.tsx | 30 +++++++++++++ components/calendar/calendar-app.tsx | 9 +++- .../use-refresh-gesture-desktop-menu.test.tsx | 42 +++++++++++++++++++ hooks/use-refresh-gesture.tsx | 7 ++++ lib/desktop/languages.ts | 4 ++ locales/ar/common.json | 12 ++++++ locales/ca/common.json | 12 ++++++ locales/cs/common.json | 12 ++++++ locales/da/common.json | 12 ++++++ locales/de/common.json | 12 ++++++ locales/en/common.json | 12 ++++++ locales/es/common.json | 12 ++++++ locales/et/common.json | 12 ++++++ locales/fa/common.json | 12 ++++++ locales/fr/common.json | 12 ++++++ locales/he/common.json | 12 ++++++ locales/hu/common.json | 12 ++++++ locales/it/common.json | 12 ++++++ locales/ja/common.json | 12 ++++++ locales/ko/common.json | 12 ++++++ locales/lv/common.json | 12 ++++++ locales/mn/common.json | 12 ++++++ locales/nb/common.json | 12 ++++++ locales/nl/common.json | 12 ++++++ locales/pl/common.json | 12 ++++++ locales/pt/common.json | 12 ++++++ locales/ro/common.json | 12 ++++++ locales/ru/common.json | 12 ++++++ locales/sk/common.json | 12 ++++++ locales/th/common.json | 12 ++++++ locales/tr/common.json | 12 ++++++ locales/uk/common.json | 12 ++++++ locales/vi/common.json | 12 ++++++ locales/zh-TW/common.json | 12 ++++++ locales/zh/common.json | 12 ++++++ 35 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx diff --git a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx index 0fc99ee3e..7b239e204 100644 --- a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx +++ b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); import { CalendarApp } from '../calendar-app'; import { requestAppCommand } from '@/lib/app-commands'; +import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; vi.setConfig({ testTimeout: 30000 }); @@ -84,3 +85,32 @@ describe('Calendar in the desktop menus', () => { expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); }); }); + +// pim-p2-9 (2026-11-04): the desktop app's Go menu in Calendar moves through time +// (Today, Next period, Previous period) instead of offering Mail's folders. +describe('Calendar in the desktop Go menu', () => { + beforeEach(() => { + resetDesktopCommands(); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } }; + }); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetDesktopCommands(); + }); + + it('offers Today, Next period and Previous period, and each moves the week', () => { + render(); + const state = mergedDesktopState(); + expect(state['go.today']).toBe(true); + expect(state['go.nextPeriod']).toBe(true); + expect(state['go.previousPeriod']).toBe(true); + const day = () => useCalendarStore.getState().selectedDate.toDateString(); + act(() => { expect(runDesktopCommand('go.nextPeriod')).toBe(true); }); + expect(day()).toBe(new Date(2026, 9, 19).toDateString()); + act(() => { runDesktopCommand('go.previousPeriod'); }); + act(() => { runDesktopCommand('go.previousPeriod'); }); + expect(day()).toBe(new Date(2026, 9, 5).toDateString()); + act(() => { runDesktopCommand('go.today'); }); + expect(day()).toBe(new Date().toDateString()); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..b22d639a5 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1686,7 +1686,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); }, "import-calendar": () => { if (!demoMode) setShowImportModal(true); }, }, true); - // Its View menu's Day, Week, Month and Agenda, and Edit > Search events. + // Its View menu's Day, Week, Month and Agenda, Go's Today and periods, and Edit > Search events. const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }; useDesktopCommands({ "view.calendar.day": viewFromMenu("day"), @@ -1694,7 +1694,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "view.calendar.month": viewFromMenu("month"), "view.calendar.agenda": viewFromMenu("agenda"), "edit.search": openSearch, + // Go > Today, Next period and Previous period: the toolbar's arrows and T. + "go.today": () => { leaveSearch(); goToToday(); }, + "go.nextPeriod": () => { leaveSearch(); navigateNext(); }, + "go.previousPeriod": () => { leaveSearch(); navigatePrev(); }, }, { + "go.today": normalizedViewMode !== "tasks", + "go.nextPeriod": normalizedViewMode !== "tasks", + "go.previousPeriod": normalizedViewMode !== "tasks", "view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" }, "view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" }, "view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" }, diff --git a/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx new file mode 100644 index 000000000..c363bb1db --- /dev/null +++ b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { useRefreshGesture } from '@/hooks/use-refresh-gesture'; +import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; + +/** + * pim-p2-9 (2026-11-04): in the desktop app View > Refresh (F9) was Mail's "Check + * for new mail" and greyed in Calendar, Contacts and Files. Each screen's own + * refresh (the one F5 and pull-to-refresh run) now answers the menu. + */ + +function Screen({ onRefresh, enabled = true }: { onRefresh: () => void; enabled?: boolean }) { + const { indicator } = useRefreshGesture({ onRefresh, enabled }); + return
{indicator}
; +} + +describe('useRefreshGesture in the desktop app', () => { + beforeEach(() => { + resetDesktopCommands(); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } }; + }); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetDesktopCommands(); + }); + + it('enables View > Refresh and runs the screen refresh from it', async () => { + const onRefresh = vi.fn(); + render(); + expect(mergedDesktopState()['view.refresh']).toBe(true); + await act(async () => { + expect(runDesktopCommand('view.refresh')).toBe(true); + }); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('offers nothing while the screen can not refresh', () => { + render(); + expect(mergedDesktopState()['view.refresh']).toBeUndefined(); + expect(runDesktopCommand('view.refresh')).toBe(false); + }); +}); diff --git a/hooks/use-refresh-gesture.tsx b/hooks/use-refresh-gesture.tsx index f74737516..a4a26ac68 100644 --- a/hooks/use-refresh-gesture.tsx +++ b/hooks/use-refresh-gesture.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { useDesktopCommands } from "@/lib/desktop/commands"; import { PullToRefreshIndicator, createPullToRefreshStore, @@ -93,8 +94,12 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu const t = useTranslations("common"); const onRefreshRef = useRef(onRefresh); const runningRef = useRef(false); + const triggerRef = useRef<(() => void) | null>(null); const store = useMemo(() => createPullToRefreshStore(), []); + // The desktop app's View > Refresh (F9, F5) runs the same refresh for the screen in front. + useDesktopCommands({ "view.refresh": () => triggerRef.current?.() }, { "view.refresh": enabled }, enabled); + useEffect(() => { onRefreshRef.current = onRefresh; }, [onRefresh]); @@ -135,6 +140,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu } })(); }; + triggerRef.current = trigger; const handleKeyDown = (event: KeyboardEvent) => { const isReloadKey = @@ -240,6 +246,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu window.removeEventListener("touchcancel", cancelPull); timers.forEach(clearTimeout); timers.clear(); + if (triggerRef.current === trigger) triggerRef.current = null; store.set({ phase: "idle", distance: 0 }); }; }, [enabled, store]); diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 010b284fa..86c6d598a 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -91,6 +91,7 @@ export function shellLabels(t: (key: string, values?: Record) => menuSelectAll: t('desktopUi.menu.select_all'), menuSearch: t('desktopUi.menu.search'), menuRefresh: t('desktopUi.menu.refresh'), + menuRefreshView: t('ux_p2_pim_cm.menu_refresh'), menuReadingPane: t('desktopUi.menu.reading_pane'), menuPaneRight: t('desktopUi.menu.pane_right'), menuPaneBelow: t('desktopUi.menu.pane_below'), @@ -112,6 +113,9 @@ export function shellLabels(t: (key: string, values?: Record) => menuDelete: t('context_menu.delete'), menuOpenInWindow: t('desktopUi.menu.open_in_window'), menuGoInbox: t('sidebar.mailboxes.inbox'), + menuGoToday: t('ux_p2_pim_cm.menu_today'), + menuGoNextPeriod: t('ux_p2_pim_cm.menu_next_period'), + menuGoPreviousPeriod: t('ux_p2_pim_cm.menu_previous_period'), menuGoSent: t('sidebar.mailboxes.sent'), menuGoDrafts: t('sidebar.mailboxes.drafts'), menuGoNext: t('desktopUi.menu.next_message'), diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..e0d3199a4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "التقويم…", "import_contacts": "جهات الاتصال…" }, + "ux_p2_pim_cm": { + "menu_refresh": "تحديث", + "menu_today": "اليوم", + "menu_next_period": "الفترة التالية", + "menu_previous_period": "الفترة السابقة", + "account_scope_label": "يعرض {address}. تبديل الحساب", + "account_scope_menu": "تبديل الحساب", + "merge_links": "روابط", + "merge_photo": "صورة", + "merge_kept": "محفوظ", + "import_show": "عرض" + }, "desktopEdit": { "edit": "تعديل", "edit_named": "تعديل {name}", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..87286548a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendari…", "import_contacts": "Contactes…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualitza", + "menu_today": "Avui", + "menu_next_period": "Període següent", + "menu_previous_period": "Període anterior", + "account_scope_label": "Es mostra {address}. Canvia de compte", + "account_scope_menu": "Canvia de compte", + "merge_links": "Enllaços", + "merge_photo": "Foto", + "merge_kept": "Es conserva", + "import_show": "Mostra" + }, "desktopEdit": { "edit": "Edita", "edit_named": "Edita {name}", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..9129e66e7 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendář…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Obnovit", + "menu_today": "Dnes", + "menu_next_period": "Další období", + "menu_previous_period": "Předchozí období", + "account_scope_label": "Zobrazuje se {address}. Přepnout účet", + "account_scope_menu": "Přepnout účet", + "merge_links": "Odkazy", + "merge_photo": "Fotka", + "merge_kept": "Zůstane", + "import_show": "Zobrazit" + }, "desktopEdit": { "edit": "Upravit", "edit_named": "Upravit {name}", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..4da1fc158 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakter…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Opdater", + "menu_today": "I dag", + "menu_next_period": "Næste periode", + "menu_previous_period": "Forrige periode", + "account_scope_label": "Viser {address}. Skift konto", + "account_scope_menu": "Skift konto", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Beholdes", + "import_show": "Vis" + }, "desktopEdit": { "edit": "Rediger", "edit_named": "Rediger {name}", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..f587c4eda 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakte…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Aktualisieren", + "menu_today": "Heute", + "menu_next_period": "Nächster Zeitraum", + "menu_previous_period": "Vorheriger Zeitraum", + "account_scope_label": "Zeigt {address}. Konto wechseln", + "account_scope_menu": "Konto wechseln", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Bleibt erhalten", + "import_show": "Anzeigen" + }, "desktopEdit": { "edit": "Bearbeiten", "edit_named": "{name} bearbeiten", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..b708501bf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendar…", "import_contacts": "Contacts…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Refresh", + "menu_today": "Today", + "menu_next_period": "Next period", + "menu_previous_period": "Previous period", + "account_scope_label": "Showing {address}. Switch account", + "account_scope_menu": "Switch account", + "merge_links": "Links", + "merge_photo": "Photo", + "merge_kept": "Kept", + "import_show": "Show" + }, "desktopEdit": { "edit": "Edit", "edit_named": "Edit {name}", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..b15556a39 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendario…", "import_contacts": "Contactos…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualizar", + "menu_today": "Hoy", + "menu_next_period": "Periodo siguiente", + "menu_previous_period": "Periodo anterior", + "account_scope_label": "Se muestra {address}. Cambiar de cuenta", + "account_scope_menu": "Cambiar de cuenta", + "merge_links": "Enlaces", + "merge_photo": "Foto", + "merge_kept": "Se conserva", + "import_show": "Mostrar" + }, "desktopEdit": { "edit": "Editar", "edit_named": "Editar {name}", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..cebb3038c 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontaktid…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Värskenda", + "menu_today": "Täna", + "menu_next_period": "Järgmine periood", + "menu_previous_period": "Eelmine periood", + "account_scope_label": "Kuvatakse {address}. Vaheta kontot", + "account_scope_menu": "Vaheta kontot", + "merge_links": "Lingid", + "merge_photo": "Foto", + "merge_kept": "Jääb alles", + "import_show": "Näita" + }, "desktopEdit": { "edit": "Muuda", "edit_named": "Muuda faili {name}", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..868dcbc6c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "تقویم…", "import_contacts": "مخاطبین…" }, + "ux_p2_pim_cm": { + "menu_refresh": "بازخوانی", + "menu_today": "امروز", + "menu_next_period": "بازه بعدی", + "menu_previous_period": "بازه قبلی", + "account_scope_label": "نمایش {address}. تغییر حساب", + "account_scope_menu": "تغییر حساب", + "merge_links": "پیوندها", + "merge_photo": "عکس", + "merge_kept": "حفظ می‌شود", + "import_show": "نمایش" + }, "desktopEdit": { "edit": "ویرایش", "edit_named": "ویرایش {name}", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..386b54271 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendrier…", "import_contacts": "Contacts…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualiser", + "menu_today": "Aujourd’hui", + "menu_next_period": "Période suivante", + "menu_previous_period": "Période précédente", + "account_scope_label": "Affiche {address}. Changer de compte", + "account_scope_menu": "Changer de compte", + "merge_links": "Liens", + "merge_photo": "Photo", + "merge_kept": "Conservé", + "import_show": "Afficher" + }, "desktopEdit": { "edit": "Modifier", "edit_named": "Modifier {name}", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..d3ff2084f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7520,6 +7520,18 @@ "import_calendar": "לוח שנה…", "import_contacts": "אנשי קשר…" }, + "ux_p2_pim_cm": { + "menu_refresh": "רענון", + "menu_today": "היום", + "menu_next_period": "התקופה הבאה", + "menu_previous_period": "התקופה הקודמת", + "account_scope_label": "מוצג {address}. החלפת חשבון", + "account_scope_menu": "החלפת חשבון", + "merge_links": "קישורים", + "merge_photo": "תמונה", + "merge_kept": "נשמר", + "import_show": "הצגה" + }, "desktopEdit": { "edit": "עריכה", "edit_named": "עריכת {name}", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..96a881a27 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Naptár…", "import_contacts": "Névjegyek…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Frissítés", + "menu_today": "Ma", + "menu_next_period": "Következő időszak", + "menu_previous_period": "Előző időszak", + "account_scope_label": "Látható: {address}. Fiókváltás", + "account_scope_menu": "Fiókváltás", + "merge_links": "Hivatkozások", + "merge_photo": "Fénykép", + "merge_kept": "Megmarad", + "import_show": "Megjelenítés" + }, "desktopEdit": { "edit": "Szerkesztés", "edit_named": "{name} szerkesztése", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..311905906 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendario…", "import_contacts": "Contatti…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Aggiorna", + "menu_today": "Oggi", + "menu_next_period": "Periodo successivo", + "menu_previous_period": "Periodo precedente", + "account_scope_label": "Mostra {address}. Cambia account", + "account_scope_menu": "Cambia account", + "merge_links": "Link", + "merge_photo": "Foto", + "merge_kept": "Mantenuto", + "import_show": "Mostra" + }, "desktopEdit": { "edit": "Modifica", "edit_named": "Modifica {name}", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..321e6e60c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "カレンダー…", "import_contacts": "連絡先…" }, + "ux_p2_pim_cm": { + "menu_refresh": "更新", + "menu_today": "今日", + "menu_next_period": "次の期間", + "menu_previous_period": "前の期間", + "account_scope_label": "{address} を表示中。アカウントを切り替え", + "account_scope_menu": "アカウントを切り替え", + "merge_links": "リンク", + "merge_photo": "写真", + "merge_kept": "残ります", + "import_show": "表示" + }, "desktopEdit": { "edit": "編集", "edit_named": "{name} を編集", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..3b329a19e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "캘린더…", "import_contacts": "연락처…" }, + "ux_p2_pim_cm": { + "menu_refresh": "새로 고침", + "menu_today": "오늘", + "menu_next_period": "다음 기간", + "menu_previous_period": "이전 기간", + "account_scope_label": "{address} 표시 중. 계정 전환", + "account_scope_menu": "계정 전환", + "merge_links": "링크", + "merge_photo": "사진", + "merge_kept": "유지됨", + "import_show": "보기" + }, "desktopEdit": { "edit": "편집", "edit_named": "{name} 편집", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..855030759 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendārs…", "import_contacts": "Kontakti…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Atsvaidzināt", + "menu_today": "Šodien", + "menu_next_period": "Nākamais periods", + "menu_previous_period": "Iepriekšējais periods", + "account_scope_label": "Rāda {address}. Pārslēgt kontu", + "account_scope_menu": "Pārslēgt kontu", + "merge_links": "Saites", + "merge_photo": "Foto", + "merge_kept": "Saglabājas", + "import_show": "Rādīt" + }, "desktopEdit": { "edit": "Rediģēt", "edit_named": "Rediģēt {name}", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..58af27e1d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Хуанли…", "import_contacts": "Харилцагчид…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Шинэчлэх", + "menu_today": "Өнөөдөр", + "menu_next_period": "Дараагийн үе", + "menu_previous_period": "Өмнөх үе", + "account_scope_label": "{address}-г харуулж байна. Бүртгэл солих", + "account_scope_menu": "Бүртгэл солих", + "merge_links": "Холбоосууд", + "merge_photo": "Зураг", + "merge_kept": "Хадгалагдана", + "import_show": "Харуулах" + }, "desktopEdit": { "edit": "Засах", "edit_named": "{name} засах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..ecad3c884 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakter…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Oppdater", + "menu_today": "I dag", + "menu_next_period": "Neste periode", + "menu_previous_period": "Forrige periode", + "account_scope_label": "Viser {address}. Bytt konto", + "account_scope_menu": "Bytt konto", + "merge_links": "Lenker", + "merge_photo": "Bilde", + "merge_kept": "Beholdes", + "import_show": "Vis" + }, "desktopEdit": { "edit": "Rediger", "edit_named": "Rediger {name}", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..5a4b4d8e9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Agenda…", "import_contacts": "Contacten…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Vernieuwen", + "menu_today": "Vandaag", + "menu_next_period": "Volgende periode", + "menu_previous_period": "Vorige periode", + "account_scope_label": "Toont {address}. Ander account kiezen", + "account_scope_menu": "Ander account kiezen", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Blijft bewaard", + "import_show": "Tonen" + }, "desktopEdit": { "edit": "Bewerken", "edit_named": "{name} bewerken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..3f117e8e4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendarz…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Odśwież", + "menu_today": "Dzisiaj", + "menu_next_period": "Następny okres", + "menu_previous_period": "Poprzedni okres", + "account_scope_label": "Wyświetlane: {address}. Przełącz konto", + "account_scope_menu": "Przełącz konto", + "merge_links": "Linki", + "merge_photo": "Zdjęcie", + "merge_kept": "Zostaje", + "import_show": "Pokaż" + }, "desktopEdit": { "edit": "Edytuj", "edit_named": "Edytuj {name}", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..77ef679e5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendário…", "import_contacts": "Contactos…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Atualizar", + "menu_today": "Hoje", + "menu_next_period": "Próximo período", + "menu_previous_period": "Período anterior", + "account_scope_label": "A mostrar {address}. Mudar de conta", + "account_scope_menu": "Mudar de conta", + "merge_links": "Ligações", + "merge_photo": "Foto", + "merge_kept": "Mantém-se", + "import_show": "Mostrar" + }, "desktopEdit": { "edit": "Editar", "edit_named": "Editar {name}", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..49a41b6b8 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendar…", "import_contacts": "Contacte…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Reîmprospătează", + "menu_today": "Astăzi", + "menu_next_period": "Perioada următoare", + "menu_previous_period": "Perioada anterioară", + "account_scope_label": "Se afișează {address}. Schimbă contul", + "account_scope_menu": "Schimbă contul", + "merge_links": "Linkuri", + "merge_photo": "Fotografie", + "merge_kept": "Se păstrează", + "import_show": "Afișează" + }, "desktopEdit": { "edit": "Editează", "edit_named": "Editează {name}", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..645099a3e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Календарь…", "import_contacts": "Контакты…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Обновить", + "menu_today": "Сегодня", + "menu_next_period": "Следующий период", + "menu_previous_period": "Предыдущий период", + "account_scope_label": "Показан {address}. Сменить учётную запись", + "account_scope_menu": "Сменить учётную запись", + "merge_links": "Ссылки", + "merge_photo": "Фото", + "merge_kept": "Сохранится", + "import_show": "Показать" + }, "desktopEdit": { "edit": "Изменить", "edit_named": "Изменить {name}", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..580d05569 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendár…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Obnoviť", + "menu_today": "Dnes", + "menu_next_period": "Ďalšie obdobie", + "menu_previous_period": "Predchádzajúce obdobie", + "account_scope_label": "Zobrazuje sa {address}. Prepnúť účet", + "account_scope_menu": "Prepnúť účet", + "merge_links": "Odkazy", + "merge_photo": "Fotka", + "merge_kept": "Zostane", + "import_show": "Zobraziť" + }, "desktopEdit": { "edit": "Upraviť", "edit_named": "Upraviť {name}", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..f578d1ebd 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7527,6 +7527,18 @@ "import_calendar": "ปฏิทิน…", "import_contacts": "ผู้ติดต่อ…" }, + "ux_p2_pim_cm": { + "menu_refresh": "รีเฟรช", + "menu_today": "วันนี้", + "menu_next_period": "ช่วงถัดไป", + "menu_previous_period": "ช่วงก่อนหน้า", + "account_scope_label": "กำลังแสดง {address} สลับบัญชี", + "account_scope_menu": "สลับบัญชี", + "merge_links": "ลิงก์", + "merge_photo": "รูปภาพ", + "merge_kept": "เก็บไว้", + "import_show": "แสดง" + }, "desktopEdit": { "edit": "แก้ไข", "edit_named": "แก้ไข {name}", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..46e5728d6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Takvim…", "import_contacts": "Kişiler…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Yenile", + "menu_today": "Bugün", + "menu_next_period": "Sonraki dönem", + "menu_previous_period": "Önceki dönem", + "account_scope_label": "{address} gösteriliyor. Hesap değiştir", + "account_scope_menu": "Hesap değiştir", + "merge_links": "Bağlantılar", + "merge_photo": "Fotoğraf", + "merge_kept": "Korunur", + "import_show": "Göster" + }, "desktopEdit": { "edit": "Düzenle", "edit_named": "{name} dosyasını düzenle", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..0cc59c14a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Календар…", "import_contacts": "Контакти…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Оновити", + "menu_today": "Сьогодні", + "menu_next_period": "Наступний період", + "menu_previous_period": "Попередній період", + "account_scope_label": "Показано {address}. Змінити обліковий запис", + "account_scope_menu": "Змінити обліковий запис", + "merge_links": "Посилання", + "merge_photo": "Фото", + "merge_kept": "Залишиться", + "import_show": "Показати" + }, "desktopEdit": { "edit": "Редагувати", "edit_named": "Редагувати {name}", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..482dc9c53 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7531,6 +7531,18 @@ "import_calendar": "Lịch…", "import_contacts": "Danh bạ…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Làm mới", + "menu_today": "Hôm nay", + "menu_next_period": "Khoảng thời gian sau", + "menu_previous_period": "Khoảng thời gian trước", + "account_scope_label": "Đang hiển thị {address}. Chuyển tài khoản", + "account_scope_menu": "Chuyển tài khoản", + "merge_links": "Liên kết", + "merge_photo": "Ảnh", + "merge_kept": "Được giữ lại", + "import_show": "Hiển thị" + }, "desktopEdit": { "edit": "Chỉnh sửa", "edit_named": "Chỉnh sửa {name}", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..d7eff31bf 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "行事曆…", "import_contacts": "聯絡人…" }, + "ux_p2_pim_cm": { + "menu_refresh": "重新整理", + "menu_today": "今天", + "menu_next_period": "下一個期間", + "menu_previous_period": "上一個期間", + "account_scope_label": "正在顯示 {address}。切換帳號", + "account_scope_menu": "切換帳號", + "merge_links": "連結", + "merge_photo": "相片", + "merge_kept": "保留", + "import_show": "顯示" + }, "desktopEdit": { "edit": "編輯", "edit_named": "編輯 {name}", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..435704805 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "日历…", "import_contacts": "联系人…" }, + "ux_p2_pim_cm": { + "menu_refresh": "刷新", + "menu_today": "今天", + "menu_next_period": "下一时段", + "menu_previous_period": "上一时段", + "account_scope_label": "正在显示 {address}。切换账户", + "account_scope_menu": "切换账户", + "merge_links": "链接", + "merge_photo": "照片", + "merge_kept": "保留", + "import_show": "显示" + }, "desktopEdit": { "edit": "编辑", "edit_named": "编辑 {name}", From 4b0b13009342209c195a963fc9bd908efb7f438d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:16 +0100 Subject: [PATCH 14/29] feat: show which account Contacts and Files are showing, by its address, and switch from there --- components/contacts/contacts-sidebar.tsx | 7 +- components/files/files-sidebar.tsx | 7 +- .../__tests__/account-scope-label.test.tsx | 74 +++++++++++++++++ components/layout/account-scope-label.tsx | 79 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 components/layout/__tests__/account-scope-label.test.tsx create mode 100644 components/layout/account-scope-label.tsx diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 67060d001..ab482eb93 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -13,6 +13,7 @@ import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store"; import { useAccountStore } from "@/stores/account-store"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountScopeLabel } from "@/components/layout/account-scope-label"; export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; @@ -283,7 +284,11 @@ export function ContactsSidebar({ {/* Header */} {/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
- {t("title")} + {/* Which account it shows, when there are several: the address, which switches. */} +
+ {t("title")} + +
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
- {t("title")} + {/* Which account it shows, when there are several: the address, which switches. */} +
+ {t("title")} + +
{canCreate && ( false, onShare: noop, + onCreateIn: noop, onRenameFolder: noop, onDeleteFolder: noop, onRevealRecent: noop, + onDownloadRecent: noop, onRemoveRecent: noop, + } as unknown as FilesSidebarProps; + return render(); +} + +function renderContacts() { + const noop = vi.fn(); + return render( + , + ); +} + +const account = (id: string, email: string, isDefault = false) => ({ + id, email, username: email, displayName: 'Same Name', label: 'Same Name', serverUrl: 'https://mail.example.org', + isDefault, isConnected: true, hasError: false, lastLoginAt: 0, avatarColor: '#888888', +}); + +describe('the account a screen shows', () => { + beforeEach(() => { + switchAccount.mockClear(); + useAccountStore.setState({ accounts: [account('a', 'a@example.org', true), account('b', 'b@example.org')] } as never); + useAuthStore.setState({ activeAccountId: 'b', switchAccount } as never); + }); + afterEach(() => { + cleanup(); + useAccountStore.setState({ accounts: [] } as never); + }); + + for (const [name, renderScreen] of [['Files', renderFiles], ['Contacts', renderContacts]] as const) { + it(`${name} names the active account by its address and switches from there`, () => { + renderScreen(); + const label = screen.getByTestId('account-scope'); + expect(label).toHaveTextContent('b@example.org'); + expect(label).not.toHaveTextContent('Same Name'); + act(() => { fireEvent.click(label); }); + const menu = screen.getByRole('menu'); + const options = within(menu).getAllByRole('menuitem'); + expect(options.map((o) => o.textContent)).toEqual(['a@example.org', 'b@example.org']); + act(() => { fireEvent.click(options[0]); }); + expect(switchAccount).toHaveBeenCalledWith('a'); + }); + } + + it('says nothing with one account', () => { + useAccountStore.setState({ accounts: [account('b', 'b@example.org', true)] } as never); + renderFiles(); + expect(screen.queryByTestId('account-scope')).toBeNull(); + }); +}); diff --git a/components/layout/account-scope-label.tsx b/components/layout/account-scope-label.tsx new file mode 100644 index 000000000..586a46947 --- /dev/null +++ b/components/layout/account-scope-label.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Check, ChevronDown } from "@/components/icons"; +import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { sortDefaultFirst } from "@/lib/account-utils"; +import { useDesktopPolicy } from "@/lib/desktop/policy"; +import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; +import { toUnicodeEmail } from "@/lib/idn"; +import { cn } from "@/lib/utils"; +import { useAccountStore, type AccountEntry } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; + +const addressOf = (account: AccountEntry) => toUnicodeEmail(account.email || account.username); + +/** + * Which account a screen that shows one account at a time (Contacts, Files) is + * showing, by its address, under the screen's title. A click lists the other + * signed-in accounts by address and switches. Nothing with a single account. + */ +export function AccountScopeLabel({ className }: { className?: string }) { + const t = useTranslations("ux_p2_pim_cm"); + const storedAccounts = useAccountStore((s) => s.accounts); + const devicePolicy = useDesktopPolicy(); + const accounts = useMemo( + () => sortDefaultFirst(devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts), + [storedAccounts, devicePolicy], + ); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const switchAccount = useAuthStore((s) => s.switchAccount); + const menu = useContextMenu(); + + const active = accounts.find((account) => account.id === activeAccountId); + if (accounts.length < 2 || !active) return null; + const address = addressOf(active); + + return ( + <> + + + {accounts.map((account) => ( + { + menu.closeContextMenu(); + void switchAccount(account.id); + }} + /> + ))} + + + ); +} 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 15/29] 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 16/29] 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(); + }); +}); From 5365b1a4612e40ca69da0c0f2d8befd5468f0c33 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:59:28 +0100 Subject: [PATCH 17/29] fix: lay out addresses in the merge preview as the card does, list every field the card keeps, and sort Merge with by name The contacts-app merge test now expects the address on its own lines instead of one comma-separated line, which was the bug. --- .../contact-merge-preview-ux-p2.test.tsx | 56 +++++++++++++++ .../contacts/__tests__/contacts-app.test.tsx | 3 +- components/contacts/contact-merge-dialog.tsx | 68 +++++++++++-------- 3 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx diff --git a/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx new file mode 100644 index 000000000..61ac6009d --- /dev/null +++ b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { ContactMergeDialog } from '../contact-merge-dialog'; + +// pim-p2-14 and pim-p2-15 (2026-11-04): the merge preview listed "Hauptstraße 5, +// Berlin, 10115, Germany" where the card shows "Hauptstraße 5" / "10115 Berlin", and +// left out the birthday the merge keeps. "Merge with…" listed every contact unsorted. + +afterEach(() => cleanup()); + +const anna = { + id: 'anna', kind: 'individual', name: { full: 'Anna Schmidt' }, + emails: { e: { address: 'anna@example.org' } }, + addresses: { + a: { + countryCode: 'DE', + components: [ + { kind: 'name', value: 'Hauptstraße' }, { kind: 'number', value: '5' }, + { kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' }, + ], + }, + }, + updated: '2026-11-01T10:00:00Z', +} as unknown as ContactCard; + +const annaOld = { + id: 'anna-old', kind: 'individual', name: { full: 'Anna Schmidt' }, + emails: { e: { address: 'anna.s@example.org' } }, + anniversaries: { b: { kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } }, + updated: '2025-01-01T10:00:00Z', +} as unknown as ContactCard; + +const named = (id: string, full: string) => ({ id, kind: 'individual', name: { full } }) as unknown as ContactCard; + +describe('the merge preview', () => { + it('lays the address out as the card does and lists the birthday it keeps', () => { + render(); + const preview = screen.getByTestId('merge-preview'); + const address = within(preview).getByText('group_adr_any').nextElementSibling as HTMLElement; + expect(address.textContent).toBe('Hauptstraße 5\n10115 Berlin\nGermany'); + const birthday = within(preview).getByText('detail.anniversary_birth').nextElementSibling as HTMLElement; + expect(birthday.textContent).toMatch(/1990/); + }); +}); + +describe('Merge with…', () => { + it('lists the contacts to pick by name', () => { + const candidates = [named('3', 'Jürgen'), named('1', 'Alice'), named('5', 'Zoë'), named('2', 'anna'), named('4', 'Bob')]; + render(); + const rows = within(screen.getByTestId('merge-pick-list')).getAllByRole('button'); + expect(rows.map((row) => row.textContent)).toEqual(['Alice', 'anna', 'Bob', 'Jürgen', 'Zoë']); + fireEvent.click(rows[0]); + expect(screen.getByTestId('merge-preview')).toBeInTheDocument(); + }); +}); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 59c673620..ab4c422ed 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -322,7 +322,8 @@ describe('ContactsApp', () => { const preview = await screen.findByTestId('merge-preview'); expect(within(preview).getByText('anna@example.net')).toBeInTheDocument(); expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument(); - expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument(); + // One line per address line, as the card shows it (pim-p2-14); getByText joins the lines with a space. + expect(within(preview).getByText('Hauptstraße 1 Berlin')).toBeInTheDocument(); expect(within(preview).getByText('Club')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'merge' })); diff --git a/components/contacts/contact-merge-dialog.tsx b/components/contacts/contact-merge-dialog.tsx index bc48e5e99..e3cf539f9 100644 --- a/components/contacts/contact-merge-dialog.tsx +++ b/components/contacts/contact-merge-dialog.tsx @@ -1,39 +1,20 @@ "use client"; import { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Search } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { formatAddressLines } from "@/lib/address-format"; +import { formatContactDate } from "@/lib/contact-dates"; import { cn } from "@/lib/utils"; -import type { ContactAddress, ContactCard } from "@/lib/jmap/types"; +import type { ContactCard } from "@/lib/jmap/types"; import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; -/** One line for an address, as its parts and separators say. */ -export function addressLine(address: ContactAddress): string { - const components = address.components ?? []; - if (components.length > 0) { - let out = ""; - let lastWasValue = false; - for (const c of components) { - if (c.kind === "separator") { - out += c.value; - lastWasValue = false; - continue; - } - if (!c.value) continue; - if (lastWasValue) out += address.defaultSeparator ?? ", "; - out += c.value.replace(/\s*\n\s*/g, ", "); - lastWasValue = true; - } - return out; - } - const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country] - .filter(Boolean).join(", "); - return legacy || address.full || address.fullAddress || ""; -} +/** Lines of one value, shown with whitespace-pre-line. */ +const NEWLINE = String.fromCharCode(10); interface ContactMergeDialogProps { /** Two or more cards: the dialog opens on what the merge keeps. */ @@ -69,11 +50,14 @@ function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) { export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) { const t = useTranslations("ux_pim_cimport"); const tc = useTranslations("contacts"); + const tm = useTranslations("ux_p2_pim_cm"); + const locale = useLocale(); const [cards, setCards] = useState(initialCards && initialCards.length > 1 ? initialCards : null); const [query, setQuery] = useState(""); const [busy, setBusy] = useState(false); const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]); + const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }), [locale]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -81,8 +65,10 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = return candidates .filter((c) => !suggestedIds.has(c.id)) .filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q)) + // By name, as the contact list sorts, in the UI language's order. + .sort((a, b) => collator.compare(getContactDisplayName(a) || getContactPrimaryEmail(a) || "", getContactDisplayName(b) || getContactPrimaryEmail(b) || "")) .slice(0, 200); - }, [candidates, suggested, query]); + }, [candidates, suggested, query, collator]); if (!plan) { const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : ""; @@ -135,11 +121,12 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = .filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k))) .map((g) => getContactDisplayName(g)) .filter(Boolean); - const rows: Array<{ label: string; values: string[] }> = [ + const rows: Array<{ label: string; values: string[]; multiline?: boolean }> = [ { label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) }, { label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) }, { label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) }, - { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) }, + // Laid out as the card shows it ("Hauptstraße 5" / "10115 Berlin"), not the parts in stored order. + { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map((a) => formatAddressLines(a, locale).join(NEWLINE)).filter(Boolean), multiline: true }, { label: t("group_org"), values: [ @@ -147,8 +134,29 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = ...Object.values(r.titles ?? {}).map((ti) => ti.name), ].filter(Boolean), }, + { label: t("t_nickname"), values: Object.values(r.nicknames ?? {}).map((n) => n.name).filter(Boolean) }, + // Every date the kept card will have, each under its own name ("Birthday"). + ...(["birth", "wedding", "death", "other"] as const).map((kind) => ({ + label: tc(`detail.anniversary_${kind}`), + values: Object.values(r.anniversaries ?? {}).filter((a) => a.kind === kind && a.date).map((a) => formatContactDate(a.date, locale)), + })), + { + label: tc("detail.gender"), + values: [ + r.speakToAs?.grammaticalGender ? tc(`detail.gender_${r.speakToAs.grammaticalGender}`, { defaultValue: r.speakToAs.grammaticalGender }) : "", + ...Object.values(r.speakToAs?.pronouns ?? {}).map((p) => p.pronouns), + ].filter(Boolean), + }, + { label: tc("detail.language_label"), values: Object.values(r.preferredLanguages ?? {}).map((l) => l.language).filter(Boolean) }, + { label: tc("detail.personal_info"), values: Object.values(r.personalInfo ?? {}).map((pi) => pi.value).filter(Boolean) }, { label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) }, - { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) }, + { label: tm("merge_links"), values: [...Object.values(r.links ?? {}), ...Object.values(r.directories ?? {})].map((l) => l.uri).filter(Boolean) }, + { label: tc("detail.calendar_uri"), values: Object.values(r.calendars ?? {}).map((c) => c.uri).filter(Boolean) }, + { label: tc("detail.scheduling_uri"), values: Object.values(r.schedulingAddresses ?? {}).map((c) => c.uri).filter(Boolean) }, + { label: tc("detail.related_contacts"), values: Object.keys(r.relatedTo ?? {}) }, + { label: tc("detail.crypto_keys"), values: Object.keys(r.cryptoKeys ?? {}).length > 0 ? [tm("merge_kept")] : [] }, + { label: tm("merge_photo"), values: Object.keys(r.media ?? {}).length > 0 ? [tm("merge_kept")] : [] }, + { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean), multiline: true }, { label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) }, { label: t("groups"), values: groupNames }, ].filter((row) => row.values.length > 0); @@ -188,7 +196,7 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
{row.label}
{row.values.map((value, i) => ( -
{value}
+
{value}
))}
From 77986dddc93fc90780f62fc6f7bd2b1497f7d96d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:59:29 +0100 Subject: [PATCH 18/29] fix: mark a repeated address within an imported file as a duplicate, and end the contact import with a toast that shows them --- .../__tests__/contact-import-ux-p2.test.tsx | 45 +++++++++++++++++++ components/contacts/contact-import-dialog.tsx | 14 +++++- components/contacts/contacts-app.tsx | 16 +++++++ lib/__tests__/vcard.test.ts | 11 +++++ lib/vcard.ts | 17 ++++--- 5 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 components/contacts/__tests__/contact-import-ux-p2.test.tsx diff --git a/components/contacts/__tests__/contact-import-ux-p2.test.tsx b/components/contacts/__tests__/contact-import-ux-p2.test.tsx new file mode 100644 index 000000000..9bc0fc74f --- /dev/null +++ b/components/contacts/__tests__/contact-import-ux-p2.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { ContactImportDialog } from '../contact-import-dialog'; + +// pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both, +// and the import ended in a dialog page that only had Close. + +const CSV = [ + 'First Name,Last Name,E-mail Address', + 'Anna,Schmidt,anna@example.org', + 'Bob,Meyer,bob@example.org', + 'Anna,Schmidt,ANNA@example.org', + '', +].join('\r\n'); + +const file = () => new File([CSV], 'contacts-export.csv', { type: 'text/csv' }); + +describe('ContactImportDialog, pass 2', () => { + it('marks the second row with the same address as a duplicate, and imports the rest', async () => { + const onImport = vi.fn(async (cards: ContactCard[]) => cards.length); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'continue' })); + expect(await screen.findByText('Bob Meyer')).toBeInTheDocument(); + expect(screen.getAllByText('import.duplicate')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(onImport).toHaveBeenCalled()); + const cards = onImport.mock.calls[0][0] as ContactCard[]; + expect(cards.map((c) => Object.values(c.emails ?? {})[0]?.address)).toEqual(['anna@example.org', 'bob@example.org']); + }); + + it('closes when done and leaves the word to the caller', async () => { + const onClose = vi.fn(); + const onImported = vi.fn(); + render( + cards.length} onClose={onClose} onImported={onImported} initialFile={file()} />, + ); + fireEvent.click(await screen.findByRole('button', { name: 'continue' })); + await screen.findByText('Bob Meyer'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(onImported).toHaveBeenCalledWith(2, undefined)); + expect(onClose).toHaveBeenCalled(); + expect(screen.queryByText('import.success')).toBeNull(); + }); +}); diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 9886cf576..598a43c60 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -26,6 +26,11 @@ interface ContactImportDialogProps { addressBooks?: AddressBook[]; /** The book open in Contacts: "Import into" starts there. */ defaultAddressBookId?: string; + /** + * Done: the dialog closes and the caller says so (a toast with Show) instead of a + * page of its own that only has Close. Without it the dialog shows the count. + */ + onImported?: (count: number, addressBookId?: string) => void; } /** The file's bytes; jsdom's File has no arrayBuffer(). */ @@ -52,6 +57,7 @@ export function ContactImportDialog({ initialFile, addressBooks = [], defaultAddressBookId, + onImported, }: ContactImportDialogProps) { const t = useTranslations("contacts"); const shownName = useCollectionNames(); @@ -194,7 +200,13 @@ export function ContactImportDialog({ setIsImporting(true); try { - const count = await onImport(toImport, addressBooks.length > 0 && bookId ? bookId : undefined); + const target = addressBooks.length > 0 && bookId ? bookId : undefined; + const count = await onImport(toImport, target); + if (onImported) { + onClose(); + onImported(count, target); + return; + } setResult(count); } catch (error) { console.error('Failed to import contacts:', error); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 4e6656441..70d00e408 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -104,6 +104,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const linkSegments = useLiteLinkSegments('contacts', routeSegments); const shownName = useCollectionNames(); const t = useTranslations("contacts"); + const tImport = useTranslations("ux_p2_pim_cm"); const skeleton = useSkeletonLabels(); // Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note). const printOff = useStrictestDevices().noPrint; @@ -601,6 +602,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = addressBookId, ); }, [supportsSync, client, importContacts]); + // Done importing: a toast instead of a dialog page, and Show opens where they went. + const handleImported = useCallback((count: number, addressBookId?: string) => { + if (count <= 0) return; + toast.success(t("import.success", { count }), { + action: { + label: tImport("import_show"), + onClick: () => { + setSearchQuery(""); + handleSelectCategory(addressBookId ? { addressBookId } : "all"); + }, + }, + }); + }, [t, tImport, handleSelectCategory, setSearchQuery]); // "Import into" starts at the address book on screen. const openBookId = typeof activeCategory === "object" && "addressBookId" in activeCategory ? activeCategory.addressBookId : undefined; @@ -1679,6 +1693,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClose={launchedFile.done} addressBooks={supportsSync ? writableBooks : []} defaultAddressBookId={openBookId} + onImported={handleImported} /> )} {showImportDialog && ( @@ -1688,6 +1703,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClose={() => setShowImportDialog(false)} addressBooks={supportsSync ? writableBooks : []} defaultAddressBookId={openBookId} + onImported={handleImported} /> )} {(mergeCards || mergePickFor) && ( diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index 5f956778a..127a5221d 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -1177,6 +1177,17 @@ describe("detectDuplicates", () => { expect(dupes.get(0)).toBe("existing-1"); }); + // pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both. + it("marks a later card of the same file with the same address as a duplicate of the first", () => { + const incoming: ContactCard[] = [ + { id: "row-1", addressBookIds: {}, emails: { e0: { address: "anna@example.org" } } }, + { id: "row-2", addressBookIds: {}, emails: { e0: { address: "bob@example.org" } } }, + { id: "row-3", addressBookIds: {}, emails: { e0: { address: " Anna@Example.org" } } }, + ]; + const dupes = detectDuplicates([], incoming); + expect([...dupes.entries()]).toEqual([[2, "row-1"]]); + }); + it("returns empty map when no duplicates", () => { const existing: ContactCard[] = [ { diff --git a/lib/vcard.ts b/lib/vcard.ts index b52d69aca..6ed7b6231 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -1596,16 +1596,19 @@ export function detectDuplicates( } } + // A file can hold the same person twice (a CSV export often does): the first + // one counts, the later ones are duplicates of it, by its id. + const seenInFile = new Map(); incoming.forEach((card, idx) => { - if (card.emails) { - for (const e of Object.values(card.emails)) { - const match = existingEmails.get(e.address.toLowerCase()); - if (match) { - dupes.set(idx, match); - return; - } + const addresses = Object.values(card.emails ?? {}).map((e) => e.address?.trim().toLowerCase()).filter(Boolean) as string[]; + for (const address of addresses) { + const match = existingEmails.get(address) ?? seenInFile.get(address); + if (match) { + dupes.set(idx, match); + return; } } + for (const address of addresses) seenInFile.set(address, card.id); }); return dupes; From 112ff9040d38173f70654100861f0f515cc4197f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:01:47 +0100 Subject: [PATCH 19/29] test: check the account address on Contacts and Files, a CSV with a repeated person, and the merge preview on both servers The pass-1 import spec now expects the toast instead of the dialog's count page, and the merge address on its own lines. --- integration/tests/25-ux-pim-cimport.spec.ts | 8 +- integration/tests/26-ux-p2-pim-cm.spec.ts | 231 ++++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 integration/tests/26-ux-p2-pim-cm.spec.ts diff --git a/integration/tests/25-ux-pim-cimport.spec.ts b/integration/tests/25-ux-pim-cimport.spec.ts index 62db2e300..392ed6fb8 100644 --- a/integration/tests/25-ux-pim-cimport.spec.ts +++ b/integration/tests/25-ux-pim-cimport.spec.ts @@ -144,7 +144,8 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => { await expect(into).toHaveValue(work!.id); if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after-preview.png` }); await dialog.getByRole('button', { name: 'Import', exact: true }).click(); - await expect(dialog.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); + // pim-p2-15: the dialog closes and a toast says it. + await expect(page.getByRole('region', { name: 'Notifications' }).getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); await expect.poll(async () => (await cards(a, `${PREFIX} Import`)).length, { timeout: 30000 }).toBe(2); const imported = await cards(a, `${PREFIX} Import`); @@ -237,7 +238,10 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => { await expect(preview).toBeVisible(); await expect(preview.getByText(`merge-a-${RUN}@example.org`)).toBeVisible(); await expect(preview.getByText(`merge-b-${RUN}@example.org`)).toBeVisible(); - await expect(preview.getByText('Hauptstraße 1, 10115, Berlin')).toBeVisible(); + // pim-p2-14: laid out on its own lines as the card shows it, not one line of parts. + const addressCell = preview.locator('dd').filter({ hasText: 'Hauptstraße' }); + await expect(addressCell).toContainText('Berlin'); + await expect(addressCell).not.toContainText('Hauptstraße 1, 10115, Berlin'); await expect(preview.getByText(`${PREFIX} Group ${RUN}`)).toBeVisible(); if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-14-after.png` }); await page.getByRole('button', { name: 'Merge', exact: true }).click(); diff --git a/integration/tests/26-ux-p2-pim-cm.spec.ts b/integration/tests/26-ux-p2-pim-cm.spec.ts new file mode 100644 index 000000000..f3966ca79 --- /dev/null +++ b/integration/tests/26-ux-p2-pim-cm.spec.ts @@ -0,0 +1,231 @@ +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, pim-p2-11, p2-14 and p2-15, against a VM 106 Stalwart + * (1.0 and 0.16). Every write is checked on the server. + * + * - Contacts and Files name the account they show by its address, and switch from there. + * - A CSV with the same address on two rows imports one card, and the import ends in a toast. + * - The merge preview lays out the address as the card does and lists the birthday it keeps. + * + * Accounts: dso-ux-pim (A) and dso-ux-pim-b (B), password IT_DSO_PASSWORD. The spec + * makes its own cards, named "UXP2-cm …", and removes them again. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CONTACTS = 'urn:ietf:params:jmap:contacts'; +const RUN = Date.now().toString(36); +const PREFIX = `UXP2-cm ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Card = Record & { + id: string; + kind?: string; + name?: { full?: string; components?: Array<{ kind: string; value: string }> }; + emails?: Record; + addressBookIds?: Record; +}; + +const nameOf = (c: Card) => c.name?.full || (c.name?.components ?? []).filter((p) => p.kind !== 'separator').map((p) => p.value).join(' '); + +async function defaultBook(c: JmapClient): Promise { + const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((b) => b.isDefault) ?? list[0]).id; +} + +async function cards(c: JmapClient): Promise { + const r = await c.request([ + ['ContactCard/query', { accountId: c.accountId, limit: 2000 }, 'q'], + ['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'], + ], [CORE, CONTACTS]); + return (r.methodResponses[1][1].list as Card[]).filter((card) => nameOf(card).includes(PREFIX)); +} + +async function destroyOurs(c: JmapClient): Promise { + const ids = (await cards(c)).map((card) => card.id); + if (ids.length) await c.request([['ContactCard/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CONTACTS]); +} + +async function signIn(page: Page, email: string): Promise { + 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"]'); +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, email); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +/** The account the app has active, by its address, from the switcher. */ +async function activeEmail(page: Page): Promise { + const id = await accountSwitcher(page).getAttribute('data-active-account-id'); + if (!id) return null; + await accountSwitcher(page).click(); + const email = await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().getAttribute('data-account-email'); + await page.keyboard.press('Escape'); + return email; +} + +test.describe('Which account Contacts and Files show (pim-p2-11)', () => { + test('the address under the title names the account and switches it', async ({ browser }) => { + test.setTimeout(300_000); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await accountSwitcher(page).click(); + await page.locator('[data-testid="add-account"]').click(); + await signIn(page, B); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + + await page.goto('/contacts'); + const scope = page.getByTestId('account-scope'); + await expect(scope).toHaveText(B, { timeout: 60000 }); + await scope.click(); + const options = page.getByRole('menuitem'); + await expect(options.filter({ hasText: A })).toBeVisible(); + await expect(options.filter({ hasText: B })).toBeVisible(); + await options.filter({ hasText: A }).click(); + await expect(scope).toHaveText(A, { timeout: 60000 }); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(A); + + await page.goto('/files'); + await expect(scope).toHaveText(A, { timeout: 60000 }); + await scope.click(); + await page.getByRole('menuitem').filter({ hasText: B }).click(); + await expect(scope).toHaveText(B, { timeout: 60000 }); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + await context.close(); + }); +}); + +test.describe('Contacts import and merge, pass 2 (pim-p2-14, pim-p2-15)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + await destroyOurs(await JmapClient.connect(A, PASSWORD)); + }); + + test('a CSV with one person twice imports one card, and a toast says how many went in', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await page.goto('/contacts'); + await page.getByRole('button', { name: 'New', exact: true }).first().click(); + await page.getByRole('menuitem', { name: /^Import contacts/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const csv = [ + 'First Name,Last Name,E-mail Address', + `Anna,${PREFIX} Twice,twice-${RUN}@example.org`, + `Bob,${PREFIX} Once,once-${RUN}@example.org`, + `Anna,${PREFIX} Twice,TWICE-${RUN}@example.org`, + '', + ].join('\r\n'); + await dialog.locator('input[type="file"]').setInputFiles({ name: 'contacts-export.csv', mimeType: 'text/csv', buffer: Buffer.from(csv, 'utf-8') }); + await dialog.getByRole('button', { name: 'Continue' }).click(); + await expect(dialog.getByText(`Bob ${PREFIX} Once`)).toBeVisible(); + // The second Anna is marked and left out. + await expect(dialog.getByText('Duplicate', { exact: true })).toHaveCount(1); + await dialog.getByRole('button', { name: 'Import', exact: true }).click(); + + const toastRegion = page.getByRole('region', { name: 'Notifications' }); + await expect(toastRegion.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); + await expect(dialog).toBeHidden(); + await expect.poll(async () => (await cards(a)).length, { timeout: 30000 }).toBe(2); + const imported = await cards(a); + expect(imported.flatMap((c) => Object.values(c.emails ?? {}).map((e) => e.address.toLowerCase())).sort()) + .toEqual([`once-${RUN}@example.org`, `twice-${RUN}@example.org`]); + // Show opens where they went. + await toastRegion.getByRole('button', { name: 'Show' }).click(); + await page.getByPlaceholder(/Search contacts/).first().fill(PREFIX); + await expect(page.getByRole('option').filter({ hasText: PREFIX })).toHaveCount(2, { timeout: 30000 }); + await context.close(); + }); + + test('the merge preview shows the address as the card does and the birthday it keeps; Merge with is by name', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const book = await defaultBook(a); + const surname = `${PREFIX} Merge`; + const made = await a.request([['ContactCard/set', { + accountId: a.accountId, + create: { + one: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-a-${RUN}@example.org` } }, + addresses: { + a0: { + '@type': 'Address', countryCode: 'DE', isOrdered: true, defaultSeparator: ', ', contexts: { private: true }, + components: [ + { kind: 'name', value: 'Hauptstraße' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '5' }, + { kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' }, + ], + }, + }, + }, + two: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-b-${RUN}@example.org` } }, + anniversaries: { b0: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } }, + }, + zed: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', full: `Zed ${PREFIX} Picker` }, + }, + }, + }, '0']], [CORE, CONTACTS]); + const created = made.methodResponses[0][1].created ?? {}; + expect(Object.keys(created).sort(), JSON.stringify(made.methodResponses[0][1].notCreated)).toEqual(['one', 'two', 'zed']); + + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await page.goto('/contacts'); + await page.getByPlaceholder(/Search contacts/).first().fill(surname); + const rows = page.getByRole('option').filter({ hasText: surname }); + await expect(rows).toHaveCount(2, { timeout: 60000 }); + + // Merge with…: the list to pick from goes by name. + await rows.first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Merge with…' }).click(); + const pickList = page.getByTestId('merge-pick-list'); + await expect(pickList).toBeVisible(); + // The suggested ones come first, in their own block; "All contacts" is the last block. + const all = (await pickList.locator(':scope > div').last().getByRole('button').allInnerTexts()).map((t) => t.split('\n')[0].trim()); + expect(all.length).toBeGreaterThan(1); + expect(all).toEqual([...all].sort((x, y) => x.localeCompare(y, 'en', { sensitivity: 'base', numeric: true }))); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await rows.nth(0).click(); + await rows.nth(1).click({ modifiers: ['Control'] }); + await page.getByRole('button', { name: 'Merge…' }).first().click(); + const preview = page.getByTestId('merge-preview'); + await expect(preview).toBeVisible(); + const address = preview.locator('dd').filter({ hasText: 'Hauptstraße' }); + // Street, then postcode and city, then the country, each on its own line. + expect(await address.innerText()).toBe('Hauptstraße 5\n10115 Berlin\nGermany'); + const birthdayRow = preview.locator('div').filter({ has: page.locator('dt', { hasText: 'Birthday' }) }).last(); + await expect(birthdayRow.locator('dd')).toContainText('1990'); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(preview).toBeHidden(); + // Nothing merged: both cards are still there. + expect((await cards(a)).filter((c) => nameOf(c).includes(surname))).toHaveLength(2); + await context.close(); + }); +}); From 9d186e4af53ffce6c72b3e39c93bd7c78bcabd1f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:03:08 +0100 Subject: [PATCH 20/29] fix: say that a series invitation repeats, answer the whole series, and name the weeks declined on their own --- .../invitation-series-ux-p2-pim.test.tsx | 262 ++++++++++++++++++ .../email/calendar-invitation-banner.tsx | 58 +++- .../series-answers-ux-p2-pim.test.ts | 48 ++++ lib/calendar-invitation.ts | 30 ++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/et/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/th/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/vi/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + 34 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/invitation-series-ux-p2-pim.test.tsx create mode 100644 lib/__tests__/series-answers-ux-p2-pim.test.ts diff --git a/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx new file mode 100644 index 000000000..df63353a9 --- /dev/null +++ b/components/email/__tests__/invitation-series-ux-p2-pim.test.tsx @@ -0,0 +1,262 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CalendarInvitationBanner } from '../calendar-invitation-banner'; +import type { Email } from '@/lib/jmap/types'; + +const mocks = vi.hoisted(() => { + const pushMock = vi.fn(); + const clientMock = { + parseCalendarEvents: vi.fn(), + getAccountId: vi.fn(() => 'mail-account'), + getCalendarsAccountId: vi.fn(() => 'calendar-account'), + getCalendarEvent: vi.fn(), + queryCalendarEvents: vi.fn(async (): Promise => []), + // Who owns a shared or group account (Principal/get). + getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team' + ? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' } + : { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))), + }; + + const sourceClientMock = { + parseCalendarEvents: vi.fn(), + fetchBlob: vi.fn(), + getAccountId: vi.fn(() => 'source-mail-account'), + getCalendarsAccountId: vi.fn(() => 'source-calendar-account'), + }; + + const authState = { + client: clientMock, + primaryIdentity: { email: 'user@example.com' }, + identities: [{ email: 'user@example.com' }] as Array<{ email: string }>, + getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)), + }; + + const emailState = { + viewingAccountId: null as string | null, + selectedMailbox: 'inbox', + mailboxes: [] as Array>, + accountMailboxes: {} as Record>>, + }; + + const settingsState = { + calendarInvitationParsingEnabled: true, + }; + + const calendarState = { + calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }], + supportsCalendar: true, + importEvents: vi.fn(), + rsvpEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + events: [] as Array>, + setSelectedDate: vi.fn(), + }; + + const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => ( + typeof selector === 'function' ? selector(calendarState) : calendarState + )) as { + (selector?: (state: typeof calendarState) => unknown): unknown; + getState: () => typeof calendarState; + setState: (updater: Partial | ((state: typeof calendarState) => Partial | typeof calendarState)) => void; + }; + + useCalendarStoreMock.getState = () => calendarState; + useCalendarStoreMock.setState = (updater) => { + const nextState = typeof updater === 'function' ? updater(calendarState) : updater; + Object.assign(calendarState, nextState); + }; + + return { + pushMock, + clientMock, + sourceClientMock, + authState, + emailState, + settingsState, + calendarState, + useCalendarStoreMock, + }; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => { + const strings: Record = { + loading: 'Loading event details…', + title: 'Calendar Invitation', + organizer: 'Organized by {name}', + attendees: 'attendees', + add_to_calendar: 'Add to calendar', + added: 'Added to calendar', + rsvp_sent: 'Response sent', + already_in_calendar: 'Already in your calendar', + your_response: 'Your response: {status}', + response_accepted: 'Accepted', + response_needed: 'Needs response', + actor_response_info: '{name} responded {status}.', + actor_sent_info: 'Sent by {name}.', + actor_counter_info: '{name} proposed changes to this event.', + actor_refresh_info: '{name} asked for the latest event details.', + actor_declined_counter_info: '{name} declined the counter proposal.', + actor_note: 'Note: {comment}', + actor_unknown: 'Someone', + sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.', + attendee_mismatch_unverified_info: 'This response was sent from {sender}, while the attendee answering in the calendar data is {attendee}, and the message could not be verified.', + action_failed: 'Could not complete that calendar action.', + proposal_applied: 'Proposed changes applied.', + proposed_changes: 'Proposed changes', + change_title: 'Title', + change_time: 'Time', + change_location: 'Location', + change_description: 'Description', + change_empty: 'None', + change_from_to: '{before} -> {after}', + apply_proposal: 'Apply proposed changes', + review_proposal: 'Review proposal', + review_request: 'Review request', + view_in_calendar: 'View in calendar', + organizer_role: 'You organize this event', + accept: 'Accept', + maybe: 'Maybe', + decline: 'Decline', + select_calendar: 'Select calendar', + participant_limit: 'The server allows at most {max} participants per event.', + answered_accepted: 'You accepted on {date}.', + answered_declined: 'You declined on {date}.', + response_declined: 'Declined', + request_info: 'Respond to let the organizer know.', + repeats: 'Repeats: {rule}', + declined_dates: 'You declined {dates}.', + declined_many: 'You declined {count} meetings of this series.', + answers_series: 'Your answer is for every meeting in the series.', + 'recurrence.weekly': 'Weekly', + 'recurrence.on_days': 'on {days}', + }; + + let message = strings[key] ?? key; + if (values) { + for (const [name, value] of Object.entries(values)) { + message = message.replace(`{${name}}`, String(value)); + } + } + return message; + }, + useLocale: () => 'en-US', + useFormatter: () => ({ + dateTime: (date: Date) => date.toISOString(), + }), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: mocks.pushMock }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState), +})); + +vi.mock('@/stores/email-store', () => ({ + useEmailStore: (selector: (state: typeof mocks.emailState) => unknown) => selector(mocks.emailState), +})); + +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState), +})); + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: mocks.useCalendarStoreMock, +})); + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 512, + receivedAt: '2026-03-16T10:00:00Z', + hasAttachment: true, + attachments: [ + { + partId: '1', + blobId: 'blob-1', + size: 256, + type: 'text/calendar; method=REQUEST', + name: 'invite.ics', + }, + ], + ...overrides, + }; +} + +// pim-p2-8 (and pim-23): a weekly invitation where one week was declined said +// "Your response: Declined. You declined on Wed, Nov 4." while the calendar +// showed the following weeks unanswered, and nothing said it repeats. The +// card now reads the series as stored: its own answer, the declined weeks by +// date, that it repeats, and that the buttons answer every week. + +const me = { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' }; +const ben = { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }; +const rule = { '@type': 'RecurrenceRule', frequency: 'weekly', byDay: [{ '@type': 'NDay', day: 'tu' }] }; +const invitation = { + uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin', + recurrenceRules: [rule], participants: { me, ben }, +}; +// The series as Stalwart 1.0 stores it: Nov 3 declined on its own, as a pointer patch. +const master = { + id: 'series-1', uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', + timeZone: 'Europe/Berlin', recurrenceRules: [rule], participants: { me, ben }, + recurrenceOverrides: { '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }, +}; +// What the grid holds: the expanded occurrences, the declined one first. +const nov3 = { + ...master, id: 'occ-nov3', recurrenceId: '2026-11-03T16:00:00', recurrenceOverrides: null, + participants: { me: { ...me, participationStatus: 'declined' }, ben }, updated: '2026-11-04T09:00:00Z', +}; +const nov10 = { ...master, id: 'occ-nov10', recurrenceId: '2026-11-10T16:00:00', start: '2026-11-10T16:00:00', recurrenceOverrides: null }; + +describe('an invitation to a series', () => { + beforeEach(() => { + mocks.settingsState.calendarInvitationParsingEnabled = true; + mocks.clientMock.parseCalendarEvents.mockReset(); + mocks.clientMock.queryCalendarEvents.mockReset(); + mocks.calendarState.rsvpEvent.mockReset(); + mocks.calendarState.events = [nov3, nov10] as never; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]); + mocks.clientMock.queryCalendarEvents.mockImplementation(async () => [master]); + mocks.authState.identities = [{ email: 'user@example.com' }]; + }); + + it("shows the series' own answer and the declined week by its date", async () => { + render(); + expect(await screen.findByText('You declined Nov 3.')).toBeInTheDocument(); + expect(screen.queryByText('Your response: Declined')).not.toBeInTheDocument(); + expect(screen.queryByText(/^You declined on/)).not.toBeInTheDocument(); + expect(screen.getByText('Your response: Needs response')).toBeInTheDocument(); + expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'weekly-1' }); + }); + + it('says that it repeats, and that an answer is for every week', async () => { + render(); + expect(await screen.findByTestId('invitation-repeats')).toHaveTextContent('Repeats: Weekly on Tue'); + expect(await screen.findByText('Your answer is for every meeting in the series.')).toBeInTheDocument(); + }); + + it('answers the series, not the week the calendar held first', async () => { + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + render(); + await screen.findByText('You declined Nov 3.'); + fireEvent.click(screen.getByText('Accept')); + await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled()); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][1]).toBe('series-1'); + expect(mocks.calendarState.rsvpEvent.mock.calls[0][3]).toBe('accepted'); + }); + + it('leaves a one-off invitation as it was', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ ...invitation, uid: 'once', recurrenceRules: null }]); + render(); + await screen.findByText('Weekly sync with Ben'); + expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument(); + expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument(); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5928868d5..c794eac84 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -18,6 +18,7 @@ import { AlertCircle, ChevronDown, ChevronUp, + Repeat, } from '@/components/icons'; import { useTranslations, useLocale } from 'next-intl'; import { InvitationPrintSync } from '@/lib/print/invitation-print'; @@ -39,6 +40,8 @@ import { invitationAnsweredAt, invitationAnsweredKey, findParticipantByEmails, + isSeriesInvitation, + seriesAnswers, extractMethodFromRawIcs, type EventSummary, type InvitationMethod, @@ -55,6 +58,7 @@ import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; import { useAccountOwner } from '@/components/calendar/answer-bars'; import { useCollectionNames } from "@/hooks/use-collection-names"; import { resolveTimeZone } from '@/lib/timezone'; +import { buildRecurrenceSummary } from '@/components/calendar/recurrence-editor'; interface InvitationChangeItem { label: string; @@ -384,6 +388,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const shownName = useCollectionNames(); const t = useTranslations('email_viewer.calendar_invitation'); const tEvent = useTranslations('ux_pim_event'); + const tCalendar = useTranslations('calendar'); + const tSeries = useTranslations('ux_p2_pim_calx'); const describeError = useCalendarErrorMessage(); const locale = useLocale(); const router = useRouter(); @@ -479,6 +485,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // What removing a cancelled meeting from the calendar takes, read from the // events stored with its uid; null until known. const [cancelRemoval, setCancelRemoval] = useState(null); + // An invitation to a series is answered as a series (pim-p2-8): the series as + // stored, with the occurrences answered on their own. The calendar holds the + // occurrences, and the first of them was read as the answer. + const [seriesMaster, setSeriesMaster] = useState(null); const attachment = findCalendarAttachment(email); @@ -577,6 +587,20 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod; const isCancellation = method === 'cancel'; + const seriesUid = isSeriesInvitation(parsedEvent) ? parsedEvent?.uid ?? null : null; + useEffect(() => { + setSeriesMaster(null); + if (!client || !supportsCalendar || !seriesUid || sharedMailAccount) return; + let current = true; + client.queryCalendarEvents({ uid: seriesUid }) + .then((stored) => { + const master = stored.find((e) => e.uid === seriesUid && !e.recurrenceId && !e.isShared) ?? null; + if (current) setSeriesMaster(master); + }) + .catch(() => { /* the occurrence the calendar holds stands in */ }); + return () => { current = false; }; + }, [client, supportsCalendar, seriesUid, sharedMailAccount]); + useEffect(() => { setCancelRemoval(null); if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return; @@ -634,9 +658,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // answering there would answer for them (or, on Stalwart 0.16, for // whichever address it picks; the duplicate attendees and wrong senders of // #634). - const existingEvent = parsedEvent?.uid + const storedCopy = parsedEvent?.uid ? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)) : null; + const existingEvent = seriesMaster ?? storedCopy; + const isSeries = isSeriesInvitation(parsedEvent); + const seriesRule = isSeries && parsedEvent?.recurrenceRules?.[0] + ? buildRecurrenceSummary(parsedEvent.recurrenceRules[0], tCalendar, locale) + : null; const currentUserParticipant = existingEvent ? findParticipantByEmails(existingEvent, actingEmails) @@ -679,6 +708,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }) : null; const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar); + // "You declined Nov 3": the weeks answered no on their own, by their date. + const declinedOccurrences = seriesMaster && method === 'request' && !rsvpStatus + ? seriesAnswers(seriesMaster, actingEmails).declined + : []; + const occurrenceDate = (recurrenceId: string) => { + const at = new Date(`${recurrenceId.slice(0, 19)}Z`); + return isNaN(at.getTime()) ? recurrenceId : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(at); + }; + const declinedInfo = declinedOccurrences.length === 0 + ? null + : declinedOccurrences.length > 3 + ? tSeries('declined_many', { count: declinedOccurrences.length }) + : tSeries('declined_dates', { + dates: new Intl.ListFormat(locale, { type: 'conjunction' }).format(declinedOccurrences.map(occurrenceDate)), + }); const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null; const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null; const participationLabel = getParticipationLabel(t, currentRsvp); @@ -1043,6 +1087,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} + {seriesRule && ( + + + {tSeries('repeats', { rule: seriesRule })} + + )} {summary.location && ( @@ -1112,9 +1162,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} {/* Info / actor messages */} - {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( + {showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
{bannerInfo &&

{bannerInfo}

} + {declinedInfo &&

{declinedInfo}

} {cancellationOutdated &&

{t('cancel_outdated')}

} {actorMessage &&

{actorMessage}

} {actorSummary?.participationComment && ( @@ -1206,6 +1257,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {t('decline')} + {isSeries && ( + {tSeries('answers_series')} + )}
)} diff --git a/lib/__tests__/series-answers-ux-p2-pim.test.ts b/lib/__tests__/series-answers-ux-p2-pim.test.ts new file mode 100644 index 000000000..f1f66fe95 --- /dev/null +++ b/lib/__tests__/series-answers-ux-p2-pim.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { isSeriesInvitation, seriesAnswers } from '../calendar-invitation'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-8: the series' own answer, and the occurrences declined on their own. +// Stalwart 1.0 stores an occurrence's answer as a pointer patch, 0.16 as the +// whole participants map. + +const me = { '@type': 'Participant', email: 'me@example.org', roles: { attendee: true }, participationStatus: 'accepted' }; +const ben = { '@type': 'Participant', email: 'ben@example.org', roles: { owner: true }, participationStatus: 'accepted' }; + +function series(overrides: Record): Partial { + return { + uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }], + participants: { me, ben }, recurrenceOverrides: overrides, + } as unknown as Partial; +} + +describe('seriesAnswers', () => { + it('reads a pointer patch (Stalwart 1.0)', () => { + const result = seriesAnswers(series({ + '2026-11-17T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' }, + '2026-11-10T16:00:00': { title: 'Moved room' }, + }), ['me@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00', '2026-11-17T16:00:00'] }); + }); + + it('reads a whole participants map (Stalwart 0.16) and skips removed weeks', () => { + const result = seriesAnswers(series({ + '2026-11-03T16:00:00': { participants: { me: { ...me, participationStatus: 'declined' }, ben } }, + '2026-11-10T16:00:00': { excluded: true }, + }), ['ME@example.org']); + expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00'] }); + }); + + it('lists none when the whole series is declined', () => { + const declined = { ...me, participationStatus: 'declined' }; + const event = { ...series({ '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }), participants: { me: declined, ben } }; + expect(seriesAnswers(event as unknown as Partial, ['me@example.org'])).toEqual({ status: 'declined', declined: [] }); + }); + + it('tells a series invitation from one for a single occurrence', () => { + expect(isSeriesInvitation(series({}))).toBe(true); + expect(isSeriesInvitation({ ...series({}), recurrenceId: '2026-11-03T16:00:00' } as Partial)).toBe(false); + expect(isSeriesInvitation({ uid: 'x' })).toBe(false); + }); +}); diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index 60fdff12b..0c16debea 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -2,6 +2,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPa import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { hasAlignedAuthenticationPass } from '@/lib/email-headers'; import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone'; +import { applyOverridePatch } from '@/lib/recurrence-instances'; export type InvitationMethod = | 'publish' @@ -797,6 +798,35 @@ export function invitationAnsweredAt( * user's identities, primary first: an invite may be addressed to an alias * identity rather than the primary address (#1283). */ +/** + * An invitation to a series is answered as a series: the answer is the series' + * own, and occurrences answered no on their own are listed apart (pim-p2-8). + * The card said "You declined" for a weekly meeting when one week was declined, + * read from whichever occurrence the calendar held first. `master` is the + * series as stored, with its recurrenceOverrides; `declined` holds the + * recurrence ids (wall clock in the event's zone), oldest first. + */ +export function seriesAnswers( + master: Partial, + emails: readonly string[], +): { status: string | null; declined: string[] } { + const own = findParticipantByEmails(master, emails); + const status = own?.participant.participationStatus ?? null; + const declined: string[] = []; + for (const [recurrenceId, patch] of Object.entries(master.recurrenceOverrides ?? {})) { + if (!patch || typeof patch !== 'object' || (patch as { excluded?: unknown }).excluded === true) continue; + const occurrence = applyOverridePatch(master, patch as Record); + const mine = findParticipantByEmails(occurrence, emails); + if (mine?.participant.participationStatus === 'declined' && status !== 'declined') declined.push(recurrenceId); + } + return { status, declined: declined.sort() }; +} + +/** Whether a parsed invitation is for a whole series, not one occurrence of it. */ +export function isSeriesInvitation(event: Partial | null | undefined): boolean { + return !!event?.recurrenceRules?.length && !event.recurrenceId; +} + export function findParticipantByEmails( event: Partial, emails: readonly string[] diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..b3d2379d4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "رفضت الدعوة.", "answered_tentative_nodate": "أجبت بـ\"ربما\"." }, + "ux_p2_pim_calx": { + "repeats": "يتكرر: {rule}", + "declined_dates": "رفضت موعد {dates}.", + "declined_many": "رفضت {count} من مواعيد هذه السلسلة.", + "answers_series": "ينطبق ردك على كل مواعيد السلسلة." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..bdfacf30b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Heu rebutjat.", "answered_tentative_nodate": "Heu respost potser." }, + "ux_p2_pim_calx": { + "repeats": "Es repeteix: {rule}", + "declined_dates": "Has rebutjat el {dates}.", + "declined_many": "Has rebutjat {count} reunions d'aquesta sèrie.", + "answers_series": "La teva resposta val per a totes les reunions de la sèrie." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..40f6e5f45 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odmítli jste.", "answered_tentative_nodate": "Odpověděli jste možná." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje se: {rule}", + "declined_dates": "Odmítli jste {dates}.", + "declined_many": "Odmítli jste {count} schůzek této série.", + "answers_series": "Vaše odpověď platí pro všechny schůzky série." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..d9cb6b76c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Du afslog.", "answered_tentative_nodate": "Du svarede måske." }, + "ux_p2_pim_calx": { + "repeats": "Gentages: {rule}", + "declined_dates": "Du afslog {dates}.", + "declined_many": "Du afslog {count} møder i denne serie.", + "answers_series": "Dit svar gælder alle møder i serien." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..ec24e06d4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Sie haben abgesagt.", "answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet." }, + "ux_p2_pim_calx": { + "repeats": "Wiederholung: {rule}", + "declined_dates": "Sie haben am {dates} abgesagt.", + "declined_many": "Sie haben {count} Termine dieser Serie abgesagt.", + "answers_series": "Ihre Antwort gilt für alle Termine der Serie." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..0b497c13d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "You declined.", "answered_tentative_nodate": "You answered maybe." }, + "ux_p2_pim_calx": { + "repeats": "Repeats: {rule}", + "declined_dates": "You declined {dates}.", + "declined_many": "You declined {count} meetings of this series.", + "answers_series": "Your answer counts for every meeting in the series." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..9fefdd4b2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Rechazaste.", "answered_tentative_nodate": "Respondiste quizás." }, + "ux_p2_pim_calx": { + "repeats": "Se repite: {rule}", + "declined_dates": "Rechazaste el {dates}.", + "declined_many": "Rechazaste {count} reuniones de esta serie.", + "answers_series": "Tu respuesta vale para todas las reuniones de la serie." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..fbcf3ab92 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Keeldusite.", "answered_tentative_nodate": "Vastasite võib-olla." }, + "ux_p2_pim_calx": { + "repeats": "Kordub: {rule}", + "declined_dates": "Lükkasite tagasi kuupäeva {dates}.", + "declined_many": "Lükkasite tagasi {count} selle sarja kohtumist.", + "answers_series": "Teie vastus kehtib sarja kõigile kohtumistele." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..99f315963 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "رد کردید.", "answered_tentative_nodate": "پاسخ «شاید» دادید." }, + "ux_p2_pim_calx": { + "repeats": "تکرار: {rule}", + "declined_dates": "جلسهٔ {dates} را رد کردید.", + "declined_many": "{count} جلسه از این سری را رد کردید.", + "answers_series": "پاسخ شما برای همهٔ جلسه‌های این سری است." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..f27b3f84e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Vous avez refusé.", "answered_tentative_nodate": "Vous avez répondu peut-être." }, + "ux_p2_pim_calx": { + "repeats": "Se répète : {rule}", + "declined_dates": "Vous avez refusé le {dates}.", + "declined_many": "Vous avez refusé {count} réunions de cette série.", + "answers_series": "Votre réponse vaut pour toutes les réunions de la série." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..2a2eae497 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5120,6 +5120,12 @@ "answered_declined_nodate": "דחית.", "answered_tentative_nodate": "ענית 'אולי'." }, + "ux_p2_pim_calx": { + "repeats": "חוזר: {rule}", + "declined_dates": "דחית את {dates}.", + "declined_many": "דחית {count} פגישות בסדרה הזו.", + "answers_series": "התשובה שלך חלה על כל הפגישות בסדרה." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..957a4985e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ön elutasította.", "answered_tentative_nodate": "Ön talánnal válaszolt." }, + "ux_p2_pim_calx": { + "repeats": "Ismétlődik: {rule}", + "declined_dates": "Elutasította: {dates}.", + "declined_many": "A sorozat {count} alkalmát utasította el.", + "answers_series": "A válasza a sorozat minden alkalmára vonatkozik." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..9024cfbf1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Hai rifiutato.", "answered_tentative_nodate": "Hai risposto forse." }, + "ux_p2_pim_calx": { + "repeats": "Si ripete: {rule}", + "declined_dates": "Hai rifiutato il {dates}.", + "declined_many": "Hai rifiutato {count} riunioni di questa serie.", + "answers_series": "La tua risposta vale per tutte le riunioni della serie." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..2ade125ae 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "辞退しました。", "answered_tentative_nodate": "未定と回答しました。" }, + "ux_p2_pim_calx": { + "repeats": "繰り返し: {rule}", + "declined_dates": "{dates} は辞退しました。", + "declined_many": "このシリーズの {count} 件を辞退しました。", + "answers_series": "回答はシリーズのすべての会議に適用されます。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..9a674b669 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "거절했습니다.", "answered_tentative_nodate": "미정으로 응답했습니다." }, + "ux_p2_pim_calx": { + "repeats": "반복: {rule}", + "declined_dates": "{dates} 일정을 거절했습니다.", + "declined_many": "이 시리즈의 일정 {count}개를 거절했습니다.", + "answers_series": "답변은 시리즈의 모든 일정에 적용됩니다." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..abe70ff84 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Jūs noraidījāt.", "answered_tentative_nodate": "Jūs atbildējāt varbūt." }, + "ux_p2_pim_calx": { + "repeats": "Atkārtojas: {rule}", + "declined_dates": "Jūs atteicāt {dates}.", + "declined_many": "Jūs atteicāt {count} šīs sērijas sapulces.", + "answers_series": "Jūsu atbilde attiecas uz visām sērijas sapulcēm." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..eded34e01 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Та татгалзсан.", "answered_tentative_nodate": "Та магадгүй гэж хариулсан." }, + "ux_p2_pim_calx": { + "repeats": "Давтагдана: {rule}", + "declined_dates": "Та {dates}-ны уулзалтаас татгалзсан.", + "declined_many": "Та энэ цувралын {count} уулзалтаас татгалзсан.", + "answers_series": "Таны хариу цувралын бүх уулзалтад хамаарна." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..a5d992a26 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Du avslo.", "answered_tentative_nodate": "Du svarte kanskje." }, + "ux_p2_pim_calx": { + "repeats": "Gjentas: {rule}", + "declined_dates": "Du avslo {dates}.", + "declined_many": "Du avslo {count} møter i denne serien.", + "answers_series": "Svaret ditt gjelder alle møtene i serien." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..436a8fd2b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Je hebt geweigerd.", "answered_tentative_nodate": "Je hebt misschien geantwoord." }, + "ux_p2_pim_calx": { + "repeats": "Herhaalt: {rule}", + "declined_dates": "U hebt {dates} afgeslagen.", + "declined_many": "U hebt {count} afspraken van deze reeks afgeslagen.", + "answers_series": "Uw antwoord geldt voor alle afspraken in de reeks." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..2f576bb83 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odrzucono.", "answered_tentative_nodate": "Odpowiedziano „może”." }, + "ux_p2_pim_calx": { + "repeats": "Powtarza się: {rule}", + "declined_dates": "Odrzucono {dates}.", + "declined_many": "Odrzucono {count} spotkań z tej serii.", + "answers_series": "Twoja odpowiedź dotyczy wszystkich spotkań z serii." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..c346a8c8d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Você recusou.", "answered_tentative_nodate": "Você respondeu talvez." }, + "ux_p2_pim_calx": { + "repeats": "Repete-se: {rule}", + "declined_dates": "Você recusou {dates}.", + "declined_many": "Você recusou {count} reuniões desta série.", + "answers_series": "Sua resposta vale para todas as reuniões da série." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..e305011c3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ai refuzat.", "answered_tentative_nodate": "Ai răspuns poate." }, + "ux_p2_pim_calx": { + "repeats": "Se repetă: {rule}", + "declined_dates": "Ați refuzat {dates}.", + "declined_many": "Ați refuzat {count} întâlniri din această serie.", + "answers_series": "Răspunsul dvs. este pentru toate întâlnirile din serie." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..4267d5329 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Вы отклонили.", "answered_tentative_nodate": "Вы ответили «может быть»." }, + "ux_p2_pim_calx": { + "repeats": "Повторяется: {rule}", + "declined_dates": "Вы отклонили встречу {dates}.", + "declined_many": "Вы отклонили встреч этой серии: {count}.", + "answers_series": "Ваш ответ относится ко всем встречам серии." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..23e545cd2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Odmietli ste.", "answered_tentative_nodate": "Odpovedali ste možno." }, + "ux_p2_pim_calx": { + "repeats": "Opakuje sa: {rule}", + "declined_dates": "Odmietli ste {dates}.", + "declined_many": "Odmietli ste {count} stretnutí tejto série.", + "answers_series": "Vaša odpoveď platí pre všetky stretnutia série." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..4a785f5fa 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4638,6 +4638,12 @@ "answered_declined_nodate": "คุณปฏิเสธแล้ว", "answered_tentative_nodate": "คุณตอบว่าอาจจะ" }, + "ux_p2_pim_calx": { + "repeats": "ทำซ้ำ: {rule}", + "declined_dates": "คุณปฏิเสธวันที่ {dates}", + "declined_many": "คุณปฏิเสธ {count} การประชุมในชุดนี้", + "answers_series": "คำตอบของคุณใช้กับทุกการประชุมในชุดนี้" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..e4419a9d8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Reddettiniz.", "answered_tentative_nodate": "Belki dediniz." }, + "ux_p2_pim_calx": { + "repeats": "Tekrarlanır: {rule}", + "declined_dates": "{dates} toplantısını reddettiniz.", + "declined_many": "Bu serideki {count} toplantıyı reddettiniz.", + "answers_series": "Yanıtınız serideki tüm toplantılar için geçerlidir." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..0b2dc73fe 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "Ви відхилили.", "answered_tentative_nodate": "Ви відповіли «можливо»." }, + "ux_p2_pim_calx": { + "repeats": "Повторюється: {rule}", + "declined_dates": "Ви відхилили зустріч {dates}.", + "declined_many": "Ви відхилили зустрічей цієї серії: {count}.", + "answers_series": "Ваша відповідь стосується всіх зустрічей серії." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..89d844cd4 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5238,6 +5238,12 @@ "answered_declined_nodate": "Bạn đã từ chối.", "answered_tentative_nodate": "Bạn đã trả lời có thể." }, + "ux_p2_pim_calx": { + "repeats": "Lặp lại: {rule}", + "declined_dates": "Bạn đã từ chối ngày {dates}.", + "declined_many": "Bạn đã từ chối {count} cuộc họp trong chuỗi này.", + "answers_series": "Câu trả lời của bạn áp dụng cho mọi cuộc họp trong chuỗi." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..254ba30ee 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "你已拒絕。", "answered_tentative_nodate": "你已回覆暫定。" }, + "ux_p2_pim_calx": { + "repeats": "重複:{rule}", + "declined_dates": "您已拒絕 {dates} 的會議。", + "declined_many": "您已拒絕此系列中的 {count} 場會議。", + "answers_series": "您的回覆適用於此系列的所有會議。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..1f9dbdb79 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5226,6 +5226,12 @@ "answered_declined_nodate": "你已拒绝。", "answered_tentative_nodate": "你已答复暂定。" }, + "ux_p2_pim_calx": { + "repeats": "重复:{rule}", + "declined_dates": "您已拒绝 {dates} 的会议。", + "declined_many": "您已拒绝此系列中的 {count} 次会议。", + "answers_series": "您的回复适用于此系列的所有会议。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", From 84b8aef1a9b7b141953bcb3b77a83671e7a72779 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:04:51 +0100 Subject: [PATCH 21/29] test: check the time zone picker, Duplicate and a series invitation on the server --- integration/tests/26-ux-p2-pim-calx.spec.ts | 269 ++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 integration/tests/26-ux-p2-pim-calx.spec.ts diff --git a/integration/tests/26-ux-p2-pim-calx.spec.ts b/integration/tests/26-ux-p2-pim-calx.spec.ts new file mode 100644 index 000000000..d1c3cb5ff --- /dev/null +++ b/integration/tests/26-ux-p2-pim-calx.spec.ts @@ -0,0 +1,269 @@ +import { test, expect, type Page } from '@playwright/test'; +import { addDays, format } from 'date-fns'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, calendar fixes pim-p2-5 (the event's time zone picker), + * pim-p2-17 (Duplicate opens an unsaved copy) and pim-p2-8 (an invitation to + * a series), against a VM 106 Stalwart (1.0 and 0.16). Every UI step is + * checked on the server. + * + * Accounts: dso-ux-pim (A) and dso-ux-pim-b (B, the organiser of the series), + * password IT_DSO_PASSWORD. The spec makes its own items, titled + * "UXP2-calx …", and removes them again on both accounts. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CAL = 'urn:ietf:params:jmap:calendars'; +const MAIL = 'urn:ietf:params:jmap:mail'; +const RUN = Date.now().toString(36); +const TZ = 'Europe/Berlin'; +const PREFIX = `UXP2-calx ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Participant = { calendarAddress?: string; participationStatus?: string }; +type Obj = Record & { + id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string; + recurrenceOverrides?: Record> | null; + participants?: Record | null; +}; + +async function defaultCalendar(c: JmapClient): Promise { + const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((cal) => cal.isDefault) ?? list[0]).id; +} + +/** Events whose title starts with `prefix`, as the server keeps them (base events, not expanded). */ +async function objects(c: JmapClient, prefix: string): Promise { + const r = await c.request([ + ['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'uid', 'title', 'start', 'timeZone', 'duration', 'participants'], + }, 'g'], + ], [CORE, CAL]); + return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix)); +} + +/** Stalwart 1.0 refuses recurrenceOverrides in a get with other properties: on their own. */ +async function overrides(c: JmapClient, id: string): Promise>> { + const r = await c.request([['CalendarEvent/get', { accountId: c.accountId, ids: [id], properties: ['id', 'recurrenceOverrides'] }, '0']], [CORE, CAL]); + return (r.methodResponses[0][1].list?.[0]?.recurrenceOverrides ?? {}) as Record>; +} + +async function destroyAll(c: JmapClient, prefix: string): Promise { + const ids = (await objects(c, prefix)).map((o) => o.id); + if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]); +} + +async function mails(c: JmapClient, word: string): Promise { + const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 50 }, 'q']], [CORE, MAIL]); + return r.methodResponses[0][1].ids as string[]; +} + +async function destroyMails(c: JmapClient, word: string): Promise { + const ids = await mails(c, word); + if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]); +} + +async function createEvent(c: JmapClient, event: Record): Promise { + const r = await c.request([['CalendarEvent/set', { + accountId: c.accountId, + create: { e: { '@type': 'Event', calendarIds: { [await defaultCalendar(c)]: true }, ...event } }, + }, '0']], [CORE, CAL]); + const id = r.methodResponses[0][1].created?.e?.id as string | undefined; + expect(id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + return id!; +} + +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 }); +} + +// Event chips are buttons named ", <time>, <calendar>". +const card = (page: Page, title: string) => page.locator(`button[aria-label^="${title},"]`).first(); +const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd'); + +test.afterAll(async () => { + if (!PASSWORD) return; + for (const who of [A, B]) { + const c = await JmapClient.connect(who, PASSWORD); + await destroyAll(c, PREFIX); + await destroyMails(c, PREFIX); + } +}); + +test.describe('The event editor (pim-p2-5, pim-p2-17)', () => { + test.describe.configure({ mode: 'serial' }); + + test('picks a time zone by typing its city and saves the zone id', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(10); + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(page.locator('[data-testid="calendar-item"]').first()).toBeVisible({ timeout: 60000 }); + const title = page.getByLabel('Title', { exact: true }); + await expect(async () => { + await page.keyboard.press('n'); + await expect(title).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 60000 }); + await title.fill(`${PREFIX} Kolkata call`); + await page.getByLabel('Start date', { exact: true }).fill(when); + await page.getByLabel('Start time', { exact: true }).fill('10:00'); + await page.getByLabel('End date', { exact: true }).fill(when); + await page.getByLabel('End time', { exact: true }).fill('11:00'); + await page.getByRole('button', { name: 'Time zone', exact: true }).click(); + + // The list opens with a search: cities and offsets, no raw ids. + const search = page.getByRole('combobox', { name: 'Search time zones' }); + await expect(search).toBeFocused(); + await expect(page.getByRole('option', { name: /^New York/ }).first()).toBeVisible(); + await expect(page.getByRole('option', { name: /Africa\/Abidjan/ })).toHaveCount(0); + await search.fill('Kolkata'); + await expect(page.getByRole('option', { name: /^Kolkata.*GMT\+05:30/ })).toBeVisible(); + await search.press('Enter'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => { + const [e] = await objects(a, `${PREFIX} Kolkata call`); + return e && { start: e.start, timeZone: e.timeZone }; + }, { timeout: 30000 }).toMatchObject({ start: `${when}T10:00:00` }); + const [saved] = await objects(a, `${PREFIX} Kolkata call`); + expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(saved.timeZone); + await context.close(); + }); + + test('Duplicate creates nothing until Save, at the same date and time', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const when = day(11); + const name = `${PREFIX} retro`; + await createEvent(a, { title: name, uid: `uxp2-calx-dup-${RUN}`, start: `${when}T14:00:00`, timeZone: TZ, duration: 'PT1H' }); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/calendar/week/${when}`); + await expect(card(page, name)).toBeVisible({ timeout: 60000 }); + + // Closed without saving: nothing on the server. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + const editor = page.getByRole('dialog', { name: 'Create event' }); + await expect(editor).toBeVisible(); + await expect(page.getByLabel('Title', { exact: true })).toHaveValue(name); + await expect(page.getByLabel('Start date', { exact: true })).toHaveValue(when); + await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('14:00'); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + await editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(editor).toBeHidden(); + await page.waitForTimeout(2000); + expect(await objects(a, name)).toHaveLength(1); + + // Saved: a second event, same day and time. + await card(page, name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await expect(editor).toBeVisible(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 30000 }).toBe(2); + const both = await objects(a, name); + expect(both.map((e) => e.start)).toEqual([`${when}T14:00:00`, `${when}T14:00:00`]); + expect(new Set(both.map((e) => e.uid)).size).toBe(2); + await context.close(); + }); +}); + +test.describe('An invitation to a series (pim-p2-8)', () => { + test('says it repeats, names the week declined on its own, and answers the series', async ({ browser }) => { + test.setTimeout(300_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const first = day(14); + const second = day(21); + const name = `${PREFIX} weekly sync`; + const uid = `uxp2-calx-series-${RUN}`; + + // B invites A to a weekly series; Stalwart delivers the invitation itself. + const invite = await b.request([['CalendarEvent/set', { + accountId: b.accountId, + sendSchedulingMessages: true, + create: { + i: { + '@type': 'Event', title: name, uid, start: `${first}T16:00:00`, duration: 'PT30M', timeZone: TZ, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 4 }, + calendarIds: { [await defaultCalendar(b)]: true }, + participants: { + o: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true }, + }, + }, + }, + }, '0']], [CORE, CAL]); + expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy(); + await expect.poll(async () => (await mails(a, PREFIX)).length, { timeout: 90000 }).toBeGreaterThan(0); + const [mailId] = await mails(a, PREFIX); + await expect.poll(async () => (await objects(a, name)).length, { timeout: 60000 }).toBeGreaterThan(0); + const [copy] = await objects(a, name); + const mine = Object.entries(copy.participants ?? {}).find(([, p]) => p.calendarAddress?.toLowerCase() === `mailto:${A}`); + expect(mine, JSON.stringify(copy.participants)).toBeTruthy(); + const [pid] = mine!; + + // A declines the second week only, as the calendar does: a pointer patch + // (Stalwart 1.0), or the whole participants map where that is refused. + const recurrenceId = `${second}T16:00:00`; + const pointer = await a.request([['CalendarEvent/set', { + accountId: a.accountId, + update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { [`participants/${pid}/participationStatus`]: 'declined' } } }, + }, '0']], [CORE, CAL]); + const [kind, answer] = pointer.methodResponses[0]; + if (kind === 'error' || answer.notUpdated?.[copy.id]) { + const participants = structuredClone(copy.participants ?? {}); + participants[pid] = { ...participants[pid], participationStatus: 'declined' }; + const whole = await a.request([['CalendarEvent/set', { + accountId: a.accountId, + update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { participants } } }, + }, '0']], [CORE, CAL]); + expect(whole.methodResponses[0][1].notUpdated, JSON.stringify(whole.methodResponses[0][1].notUpdated ?? {})).toBeFalsy(); + } + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + + const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); + const page = await context.newPage(); + await login(page, A); + await page.goto(`/mail/message/${mailId}`); + + // It repeats, the series is unanswered, and the declined week is named by its date. + await expect(page.getByTestId('invitation-repeats')).toContainText('Weekly', { timeout: 60000 }); + const declinedDate = format(new Date(`${second}T12:00:00`), 'MMM d'); + await expect(page.getByTestId('invitation-declined-occurrences')).toHaveText(`You declined ${declinedDate}.`, { timeout: 30000 }); + await expect(page.getByText('Your response: Declined')).toHaveCount(0); + await expect(page.getByText(/^You declined on /)).toHaveCount(0); + await expect(page.getByTestId('invitation-answers-series')).toBeVisible(); + + // Accept answers the series; the declined week stays declined. + await page.getByRole('button', { name: 'Accept', exact: true }).first().click(); + await expect.poll(async () => { + const [e] = await objects(a, name); + return Object.values(e?.participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${A}`)?.participationStatus; + }, { timeout: 30000 }).toBe('accepted'); + expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); + await context.close(); + }); +}); From 0a78b2e47736994bec2f3e967b802269d4bd21d3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:10:44 +0100 Subject: [PATCH 22/29] fix: put a duplicated event in the original's calendar again after the calendar chooser change --- components/calendar/calendar-app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 595100544..fcf432df6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -70,7 +70,7 @@ import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; import { accountAddress, buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors, eventTargets, preferredTarget, taskTargets } from "@/lib/calendar-targets"; -import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; +import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; From 17b95d4a731677aa026199bf6e4d3e599af68b58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:12:11 +0100 Subject: [PATCH 23/29] fix: add an invitation only to a calendar that shows events in the account on screen --- .../calendar-invitation-banner.test.tsx | 27 +++++++++++++++++++ .../email/calendar-invitation-banner.tsx | 15 +++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index f048e956a..71317f4c6 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -465,6 +465,33 @@ describe('CalendarInvitationBanner', () => { expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument(); }); + // pim-p2-2: "Add to calendar" offered the account's task list, where the + // event would be stored but never shown, and other logins' calendars. + it('adds to the one calendar that shows events, never a task list or another login', async () => { + const before = mocks.calendarState.calendars; + mocks.calendarState.calendars = [ + { id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }, + { id: 'todo', name: 'To-do', color: '#16a34a', isDefault: false, isTasksOnly: true }, + { id: 'other', name: 'Other login', color: '#dc2626', isDefault: true, localAccountId: 'login-b' }, + ] as never; + try { + mocks.calendarState.importEvents.mockResolvedValue(1); + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-tl', title: 'Target check', start: '2026-03-22T13:00:00Z', participants: { + attendee: { '@type': 'Participant', email: 'user@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + } }, + ]); + render(<CalendarInvitationBanner email={makeEmail({ id: 'email-tl' })} />); + await screen.findByRole('heading', { name: 'Target check' }); + fireEvent.click(screen.getByText('Add to calendar')); + await waitFor(() => expect(mocks.calendarState.importEvents).toHaveBeenCalledTimes(1)); + expect(JSON.stringify(mocks.calendarState.importEvents.mock.calls[0])).toContain('cal-1'); + expect(screen.queryByText('To-do')).toBeNull(); + } finally { + mocks.calendarState.calendars = before; + } + }); + it('does not show RSVP actions when the current user is the organizer', async () => { mocks.clientMock.parseCalendarEvents.mockResolvedValue([ { diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index c794eac84..a921ca733 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { answerInvitation } from '@/lib/calendar-rsvp'; import { createPortal } from 'react-dom'; import { @@ -27,6 +27,7 @@ import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; import { useShallow } from "zustand/react/shallow"; import { useEmailStore } from '@/stores/email-store'; +import { eventTargets } from '@/lib/calendar-targets'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; import type { Email, CalendarEvent } from '@/lib/jmap/types'; @@ -455,7 +456,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const timeFormat = useSettingsStore((s) => s.timeFormat); // The user's zone, as getEffectiveTimeZone resolves it. const userTimeZone = resolveTimeZone(useSettingsStore((s) => s.timeZone)); - const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ + const { calendars: allCalendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ calendars: state.calendars, supportsCalendar: state.supportsCalendar, importEvents: state.importEvents, @@ -466,6 +467,16 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setSelectedDate: state.setSelectedDate, }))); + // Where an invitation can go: the calendars of the account on screen that show + // in the grid. A task list would hide the event, and another login's calendar + // would put the meeting in the wrong account (pim-p2-2). + const activeLocalAccountId = useAuthStore((s) => s.activeAccountId); + const calendars = useMemo(() => { + const mine = allCalendars.filter((c) => !c.localAccountId || c.localAccountId === activeLocalAccountId); + const targets = eventTargets(mine); + return targets.length > 0 ? targets : mine; + }, [allCalendars, activeLocalAccountId]); + const [state, setState] = useState<BannerState>('loading'); const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null); const [rsvpStatus, setRsvpStatus] = useState<string | null>(null); From f2f96e0f877679bcec16b967e76b7c6098df0abb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:18:09 +0100 Subject: [PATCH 24/29] test: name the quick create's long calendar something other than Stalwart's default, which now shows as Calendar The hover title now carries the shown name, as the select does; the old fixture used the server's default name, which is shown as "Calendar" since the settings fix. --- .../calendar/__tests__/event-popover-zone-ux-pim.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx index 1b9b9dc9b..a486bb03a 100644 --- a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx @@ -76,7 +76,8 @@ describe('event popover', () => { }); describe('quick create (pim-21)', () => { - const long = 'Stalwart Calendar (dso-ux-pim@example.org)'; + // Not Stalwart's default name: that one shows as plain "Calendar" now. + const long = 'Release planning for the spring quarter (team)'; const calendars = [ { id: 'a', name: long, color: '#3b82f6' }, { id: 'b', name: 'Work', color: '#22c55e' }, From 438a31b34aa6b0a1cfb6b0478db4fb2905da2020 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:35:35 +0100 Subject: [PATCH 25/29] fix: close the dialog on top with Escape, not the file preview behind it --- .../preview-escape-dialog-ux-p2-pim.test.tsx | 37 +++++++++++++++++++ components/files/preview-shell.tsx | 5 +++ 2 files changed, 42 insertions(+) create mode 100644 components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx diff --git a/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx b/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fa03adf36 --- /dev/null +++ b/components/files/__tests__/preview-escape-dialog-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { PreviewShell } from '../preview-shell'; +import { Dialog } from '@/components/ui/dialog'; + +// pim-p2-7: Escape in "Import to a mail folder…", opened from a saved mail's +// preview, closed the preview behind the dialog and left the dialog open. +describe('Escape with a dialog over the file preview', () => { + it('closes the dialog, not the preview', () => { + const closePreview = vi.fn(); + const closeDialog = vi.fn(); + const { getByLabelText } = render( + <> + <PreviewShell name="saved.eml" onClose={closePreview} onDownload={vi.fn()}> + <p>mail</p> + </PreviewShell> + <Dialog isOpen onClose={closeDialog} title="Import to a mail folder"> + <input aria-label="Filter" /> + </Dialog> + </>, + ); + fireEvent.keyDown(getByLabelText('Filter'), { key: 'Escape' }); + expect(closePreview).not.toHaveBeenCalled(); + expect(closeDialog).toHaveBeenCalledTimes(1); + }); + + it('still closes the preview when nothing is above it', () => { + const closePreview = vi.fn(); + render( + <PreviewShell name="saved.eml" onClose={closePreview} onDownload={vi.fn()}> + <button type="button">inside</button> + </PreviewShell>, + ); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(closePreview).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index d604d63de..aca9f089c 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -69,6 +69,11 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + // A dialog opened from the preview (Import to a mail folder…) sits on top + // and closes first; the preview stays. + const target = e.target instanceof Element ? e.target : null; + const above = target?.closest('[role="dialog"], [role="alertdialog"]'); + if (above && shellRef.current && !shellRef.current.contains(above)) return; e.stopPropagation(); onClose(); return; From acacf111de5f1054fa10d37eb788ceebb4a321eb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:36:10 +0100 Subject: [PATCH 26/29] test: find a file at the top of Files on both servers, and decline one week the way both servers take --- integration/tests/26-ux-p2-pim-calx.spec.ts | 24 ++++++++------------ integration/tests/26-ux-p2-pim-files.spec.ts | 8 +++++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/integration/tests/26-ux-p2-pim-calx.spec.ts b/integration/tests/26-ux-p2-pim-calx.spec.ts index d1c3cb5ff..45fea34bd 100644 --- a/integration/tests/26-ux-p2-pim-calx.spec.ts +++ b/integration/tests/26-ux-p2-pim-calx.spec.ts @@ -225,23 +225,19 @@ test.describe('An invitation to a series (pim-p2-8)', () => { expect(mine, JSON.stringify(copy.participants)).toBeTruthy(); const [pid] = mine!; - // A declines the second week only, as the calendar does: a pointer patch - // (Stalwart 1.0), or the whole participants map where that is refused. + // A declines the second week only: an override with the whole participants + // map, which both servers take (1.0 stores it as a patch). The series has + // no overrides yet, so the map is set whole. A pointer-only override is + // not what the calendar writes, and 0.16 turns it into a second organizer + // and then refuses every later change to the series. const recurrenceId = `${second}T16:00:00`; - const pointer = await a.request([['CalendarEvent/set', { + const participants = structuredClone(copy.participants ?? {}); + participants[pid] = { ...participants[pid], participationStatus: 'declined' }; + const declined = await a.request([['CalendarEvent/set', { accountId: a.accountId, - update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { [`participants/${pid}/participationStatus`]: 'declined' } } }, + update: { [copy.id]: { recurrenceOverrides: { [recurrenceId]: { participants } } } }, }, '0']], [CORE, CAL]); - const [kind, answer] = pointer.methodResponses[0]; - if (kind === 'error' || answer.notUpdated?.[copy.id]) { - const participants = structuredClone(copy.participants ?? {}); - participants[pid] = { ...participants[pid], participationStatus: 'declined' }; - const whole = await a.request([['CalendarEvent/set', { - accountId: a.accountId, - update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { participants } } }, - }, '0']], [CORE, CAL]); - expect(whole.methodResponses[0][1].notUpdated, JSON.stringify(whole.methodResponses[0][1].notUpdated ?? {})).toBeFalsy(); - } + expect(declined.methodResponses[0][1].notUpdated, JSON.stringify(declined.methodResponses[0][1].notUpdated ?? {})).toBeFalsy(); expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId); const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ }); diff --git a/integration/tests/26-ux-p2-pim-files.spec.ts b/integration/tests/26-ux-p2-pim-files.spec.ts index d712c554d..b39451cdc 100644 --- a/integration/tests/26-ux-p2-pim-files.spec.ts +++ b/integration/tests/26-ux-p2-pim-files.spec.ts @@ -122,8 +122,12 @@ test.describe('Files, usability pass 2', () => { // 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(); + // Where the folder made at the top sits: null on one server, the root node's id on another. + const top = (await ours(a)).find((n) => n.id === folderId)?.parentId ?? null; + await expect.poll(async () => { + const node = (await ours(a)).find((n) => n.name === `${PREFIX} on list.txt`); + return node ? node.parentId ?? null : 'missing'; + }, { timeout: 30000 }).toBe(top); // On the folder's row: into that folder. await dropFiles(page, `[data-resource="${folderName}"]`, [{ name: `${PREFIX} on row.txt`, text: 'row' }]); From 2d807b9a669879b5fed35d6ec849924dcf10f82b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:36:10 +0100 Subject: [PATCH 27/29] refactor: name the preview's ref among its key listener's inputs --- components/files/preview-shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index aca9f089c..d4a9ca1f8 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -102,7 +102,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c window.removeEventListener("keydown", onKey, { capture: true }); window.removeEventListener("keydown", onArrow); }; - }, [onClose, neighbours]); + }, [onClose, neighbours, shellRef]); return ( <div From 22edfecddf7e1ce51bedfc66bfa622b612ea3784 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:05:01 +0100 Subject: [PATCH 28/29] fix: go to the contacts or calendar import from a file's preview instead of staying in Files --- components/files/files-app.tsx | 4 ++-- lib/file-handoff-after-close.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 lib/file-handoff-after-close.ts diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 048071738..0710ca8b2 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -40,7 +40,7 @@ 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 { handOffFilesAfterClose } from "@/lib/file-handoff-after-close"; 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"; @@ -740,7 +740,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } setPreviewFile(null); setInfoFile(null); - handOffFiles([file], action === "add-calendar" ? "calendar" : "contacts"); + void handOffFilesAfterClose([file], action === "add-calendar" ? "calendar" : "contacts"); }, [appActionOf, getFileContent, t]); const appActionFor = useCallback((r: FileResource) => { const action = appActionOf(r); diff --git a/lib/file-handoff-after-close.ts b/lib/file-handoff-after-close.ts new file mode 100644 index 000000000..693385470 --- /dev/null +++ b/lib/file-handoff-after-close.ts @@ -0,0 +1,15 @@ +import { historySettled } from '@/hooks/use-close-on-back'; +import { handOffFiles, type HandoffKind } from '@/lib/launch-handoff'; + +/** + * Hands files to another app (contacts or calendar import) from a layer that is + * closing, such as the Files preview. Closing it takes its history entry back + * with a Back that lands later; the handoff's move to /contacts or /calendar, + * started before that, was undone by it and left the user in Files (pim-p2-7). + * So: let the close commit, wait for that Back, then hand off. + */ +export async function handOffFilesAfterClose(files: File[], kind: HandoffKind): Promise<void> { + await new Promise<void>((resolve) => setTimeout(resolve, 0)); + await historySettled(); + handOffFiles(files, kind); +} From d3168b33c1f6527d81969bed504c8cce62ff4546 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:19:08 +0100 Subject: [PATCH 29/29] fix: leave a file's preview open while its import opens, so the move to contacts or calendar holds --- components/files/files-app.tsx | 8 ++++---- lib/file-handoff-after-close.ts | 15 --------------- 2 files changed, 4 insertions(+), 19 deletions(-) delete mode 100644 lib/file-handoff-after-close.ts diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 0710ca8b2..2c615f1a8 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -40,7 +40,7 @@ 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 { handOffFilesAfterClose } from "@/lib/file-handoff-after-close"; +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"; @@ -738,9 +738,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { setImportEml(file); return; } - setPreviewFile(null); - setInfoFile(null); - void handOffFilesAfterClose([file], action === "add-calendar" ? "calendar" : "contacts"); + // The preview stays open: the move to the import closes it with the page. + // Closed first, its Back landed after that move and undid it (pim-p2-7). + handOffFiles([file], action === "add-calendar" ? "calendar" : "contacts"); }, [appActionOf, getFileContent, t]); const appActionFor = useCallback((r: FileResource) => { const action = appActionOf(r); diff --git a/lib/file-handoff-after-close.ts b/lib/file-handoff-after-close.ts deleted file mode 100644 index 693385470..000000000 --- a/lib/file-handoff-after-close.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { historySettled } from '@/hooks/use-close-on-back'; -import { handOffFiles, type HandoffKind } from '@/lib/launch-handoff'; - -/** - * Hands files to another app (contacts or calendar import) from a layer that is - * closing, such as the Files preview. Closing it takes its history entry back - * with a Back that lands later; the handoff's move to /contacts or /calendar, - * started before that, was undone by it and left the user in Files (pim-p2-7). - * So: let the close commit, wait for that Back, then hand off. - */ -export async function handOffFilesAfterClose(files: File[], kind: HandoffKind): Promise<void> { - await new Promise<void>((resolve) => setTimeout(resolve, 0)); - await historySettled(); - handOffFiles(files, kind); -}