mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-sidebar-drawer
This commit is contained in:
@@ -643,6 +643,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;
|
||||
|
||||
@@ -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(<CalendarApp />);
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import('@/lib/lite')>()),
|
||||
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> = {}): 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<CalendarEvent>) => ({ ...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(<CalendarApp />);
|
||||
}
|
||||
|
||||
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<CalendarEvent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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> = {}): 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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim-b@example.org']} />);
|
||||
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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim@example.org']} />);
|
||||
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(<EventModal event={event} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['pim@example.org']} />);
|
||||
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(
|
||||
<QuickCreatePopover
|
||||
draft={{ start: new Date(2026, 10, 5, 9), end: new Date(2026, 10, 5, 10), title: '', allDay: false, calendarId: bDefault.id }}
|
||||
calendars={[aDefault, aWork, bDefault]}
|
||||
onChange={vi.fn()} onSave={vi.fn()} onMoreOptions={vi.fn()} onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(<ICalImportModal calendars={calendars} client={client as never} onClose={vi.fn()} initialFile={file} />);
|
||||
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(<TaskModal calendars={[...calendars, bTodo]} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
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(<CalendarSidebarPanel calendars={calendars} selectedCalendarIds={[]} onToggleVisibility={() => {}} 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 <address>" in the event menu', () => {
|
||||
const onCopy = vi.fn();
|
||||
const menuRef = { current: null };
|
||||
render(
|
||||
<EventContextMenu
|
||||
event={event} position={{ x: 10, y: 10 }} isOpen onClose={vi.fn()} menuRef={menuRef}
|
||||
onEdit={vi.fn()} onDuplicate={vi.fn()} onExportICS={vi.fn()} onCopyTitle={vi.fn()} onDelete={vi.fn()}
|
||||
copyTo={[{ address: 'pim@example.org', onCopy }]}
|
||||
/>,
|
||||
);
|
||||
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(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
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(<AddressBookPickerDialog isOpen onClose={vi.fn()} addressBooks={books} count={1} onPick={vi.fn()} />);
|
||||
expect(screen.getAllByTestId('address-book-account').map((el) => el.textContent)).toEqual(['pim-b@example.org', 'pim@example.org']);
|
||||
});
|
||||
});
|
||||
@@ -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(<EventCard event={train} variant="block" continuesBefore />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,16 @@ async function save(onSave: ReturnType<typeof vi.fn>): Promise<{ data: Partial<C
|
||||
|
||||
const field = (label: string) => 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) });
|
||||
|
||||
@@ -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<string, unknown>) => (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(
|
||||
<EventDetailPopover event={event} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()}
|
||||
currentUserEmails={['me@example.com']} />,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>): 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(<TaskListView tasks={[task({ progress: 'completed' })]} calendars={calendars} filter="all" onSelectTask={() => {}} 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(<TaskListView tasks={[task({})]} calendars={calendars} filter="all" onSelectTask={() => {}} 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(<CalendarSidebarPanel calendars={calendars} selectedCalendarIds={['cal']} onToggleVisibility={() => {}} client={client} />);
|
||||
expect(screen.getByText(/^1 tasks\.filter_overdue$/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -123,8 +123,10 @@ describe('where a new task goes (pim-8)', () => {
|
||||
});
|
||||
|
||||
it('opens the task form on the task-only list', () => {
|
||||
render(<TaskModal calendars={[home, work, todo]} onSave={vi.fn()} onClose={() => {}} />);
|
||||
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(<TaskModal calendars={[home, work, todo, errands]} onSave={vi.fn()} onClose={() => {}} />);
|
||||
expect((screen.getByLabelText('task_list_label') as HTMLSelectElement).value).toBe('todo');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
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(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
|
||||
const select = screen.getByLabelText('task_list_label') as HTMLSelectElement;
|
||||
expect(select.value).toBe('birthdays');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}<TaskModal/);
|
||||
});
|
||||
});
|
||||
@@ -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,8 +67,9 @@ 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 { accountAddress, buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors, eventTargets, preferredTarget, taskTargets } from "@/lib/calendar-targets";
|
||||
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";
|
||||
@@ -172,6 +172,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 +213,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);
|
||||
@@ -280,6 +284,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const [quickDraft, setQuickDraft] = useState<QuickCreateDraft | null>(null);
|
||||
const [defaultModalTitle, setDefaultModalTitle] = useState<string | undefined>();
|
||||
const [modalStartsInEdit, setModalStartsInEdit] = useState(false);
|
||||
// Duplicate opens the editor on an unsaved copy of this event (pim-p2-17).
|
||||
const [copySource, setCopySource] = useState<CalendarEvent | null>(null);
|
||||
useEffect(() => {
|
||||
if (!showEventModal) setCopySource(null);
|
||||
}, [showEventModal]);
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
|
||||
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
|
||||
@@ -740,6 +749,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 +762,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);
|
||||
@@ -1193,6 +1204,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 +1233,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 +1256,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 +1272,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.
|
||||
@@ -1335,24 +1352,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
};
|
||||
}, [quickDraft, pendingPreview, t]);
|
||||
|
||||
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||
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 +1557,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 +1577,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 {
|
||||
@@ -1616,6 +1615,35 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||
}, [handleDeleteEvent]);
|
||||
|
||||
// "Copy to <other account>" (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
|
||||
@@ -1686,7 +1714,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 +1722,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" },
|
||||
@@ -1740,7 +1775,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 +2123,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;
|
||||
@@ -2424,10 +2460,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
on small screens, so the week keeps its width (pim-25). Above
|
||||
the views' sticky headers (z-50) inside this isolated row. */}
|
||||
{!isMobile && showEventModal && (
|
||||
<div data-testid="event-panel" className="absolute inset-y-0 end-0 z-[55] w-[400px] max-w-full border-s border-border bg-background shadow-xl overflow-hidden">
|
||||
<div data-testid="event-panel" data-end-sheet="" className="absolute inset-y-0 end-0 z-[55] w-[400px] max-w-full border-s border-border bg-background shadow-xl overflow-hidden">
|
||||
<EventModal
|
||||
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
key={`${editEvent?.id ?? (copySource ? `copy:${copySource.id}` : 'new')}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
event={editEvent}
|
||||
copyOf={copySource}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
@@ -2441,9 +2478,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onRsvp={handleRsvp}
|
||||
onMore={editEvent ? (anchor) => 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}
|
||||
@@ -2453,7 +2490,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
{/* Desktop task panel */}
|
||||
{!isMobile && showTaskModal && (
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<div data-end-sheet="" className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
@@ -2519,6 +2556,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); },
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2593,8 +2634,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
{showEventModal && isMobile && (
|
||||
<EventModal
|
||||
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
key={`${editEvent?.id ?? (copySource ? `copy:${copySource.id}` : 'new')}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
event={editEvent}
|
||||
copyOf={copySource}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
@@ -2608,8 +2650,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onRsvp={handleRsvp}
|
||||
onMore={editEvent ? (anchor) => 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}
|
||||
|
||||
@@ -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 <select>.
|
||||
*/
|
||||
export function AccountGroupedOptions<T extends { id: string; localAccountId?: string }>({ items, label }: {
|
||||
items: T[];
|
||||
label: (item: T) => string;
|
||||
}) {
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
const groups = useMemo(() => groupCalendarsByAccount(items, accounts, activeAccountId), [items, accounts, activeAccountId]);
|
||||
const options = (list: T[]) => list.map((item) => <option key={item.id} value={item.id}>{label(item)}</option>);
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => group.label === null
|
||||
? options(group.calendars)
|
||||
: <optgroup key={group.key} label={group.label}>{options(group.calendars)}</optgroup>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarSelectProps {
|
||||
id?: string;
|
||||
calendars: Calendar[];
|
||||
value: string;
|
||||
onChange: (calendarId: string) => void;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
title?: string;
|
||||
/** For events: a task list that is listed only because the event already sits there gets a note. */
|
||||
kind?: "event" | "task";
|
||||
}
|
||||
|
||||
/** Every calendar and task list chooser: grouped by account, names as Settings shows them. */
|
||||
export function CalendarSelect({ id, calendars, value, onChange, className, title, kind = "event", ...rest }: CalendarSelectProps) {
|
||||
const shownName = useCollectionNames();
|
||||
const t = useTranslations("ux_p2_pim_cal");
|
||||
const label = (cal: Calendar) => {
|
||||
const name = shownName.calendar(cal);
|
||||
return kind === "event" && cal.isTasksOnly ? t("task_list_not_in_calendar", { name }) : name;
|
||||
};
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
|
||||
aria-label={rest["aria-label"]}
|
||||
title={title}
|
||||
className={className}
|
||||
data-testid="calendar-select"
|
||||
>
|
||||
<AccountGroupedOptions items={calendars} label={label} />
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -20,9 +21,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 +124,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<Calendar>();
|
||||
|
||||
@@ -209,13 +207,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 +225,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 +236,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 +296,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 +616,7 @@ export function CalendarSidebarPanel({
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground/90 truncate" title={group.label}>
|
||||
<span className="text-xs font-semibold text-foreground/90 truncate" title={group.name && group.name !== group.label ? `${group.name} <${group.label}>` : group.label} data-testid="calendar-account-group">
|
||||
{group.label}
|
||||
</span>
|
||||
{isActive && onCreateCalendar && (
|
||||
|
||||
@@ -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). */}
|
||||
<div data-event-title-line className="sticky top-[var(--grid-header-height,0px)]">
|
||||
<div className="@container">
|
||||
<div className="flex items-baseline gap-1 min-w-0">
|
||||
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
@@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleCalendarName && (
|
||||
<div data-calendar-name dir="auto" className="opacity-85 text-[0.65625rem] truncate">{visibleCalendarName}</div>
|
||||
)}
|
||||
|
||||
@@ -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 <address>", 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 && <ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />}
|
||||
<ContextMenuItem icon={CopyPlus} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||
{copyTo.map((target) => (
|
||||
<ContextMenuItem
|
||||
key={target.address}
|
||||
icon={CalendarPlus}
|
||||
label={tP2("copy_to_account", { address: target.address })}
|
||||
onClick={handle(target.onCopy)}
|
||||
testId="event-copy-to-account"
|
||||
/>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
|
||||
@@ -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<typeof useTranslations>, locale: string): string | null {
|
||||
@@ -506,7 +514,7 @@ export function EventDetailPopover({
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
({formatDurationDisplay(durationMinutes, locale)})
|
||||
</div>
|
||||
{zoned && zonedText && (
|
||||
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
|
||||
@@ -529,7 +537,7 @@ export function EventDetailPopover({
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
|
||||
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes, locale)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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";
|
||||
@@ -35,12 +36,15 @@ import {
|
||||
participantFieldsToSave,
|
||||
userOrganizes,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
|
||||
import { canUserRsvp, getEventEditability } from "@/lib/calendar-editability";
|
||||
import { eventTargets, preferredTarget } from "@/lib/calendar-targets";
|
||||
import { CalendarSelect } from "@/components/calendar/calendar-select";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/recurrence-scope-edit";
|
||||
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";
|
||||
@@ -58,7 +62,6 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { gateLinkClick } from "@/lib/plugin-hook-result";
|
||||
import { useCollectionNames } from "@/hooks/use-collection-names";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -85,7 +88,13 @@ interface EventModalProps {
|
||||
*/
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onDuplicate?: (data: Partial<CalendarEvent>) => 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 +275,7 @@ export function EventModal({
|
||||
onSave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
copyOf = null,
|
||||
onRsvp,
|
||||
onClose,
|
||||
onPreviewChange,
|
||||
@@ -276,7 +286,6 @@ export function EventModal({
|
||||
maxParticipants = null,
|
||||
isMobile = false,
|
||||
}: EventModalProps) {
|
||||
const shownName = useCollectionNames();
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
// Ids that tie each visible label to its field.
|
||||
@@ -299,6 +308,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 +357,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 +368,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 +382,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<string | null>(() => eventFormTimeZone(event, userTimeZone));
|
||||
const [timeZone, setTimeZone] = useState<string | null>(() => eventFormTimeZone(seed, userTimeZone));
|
||||
const [showZonePicker, setShowZonePicker] = useState(false);
|
||||
const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone));
|
||||
const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone));
|
||||
@@ -403,14 +414,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;
|
||||
@@ -437,37 +443,42 @@ export function EventModal({
|
||||
};
|
||||
const handleStartDateChange = (v: string) => { 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<string>(() => {
|
||||
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<RecurrenceOption>(() => {
|
||||
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<CalendarRecurrenceRule | null>(() => {
|
||||
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 +506,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<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(initialAlerts.rows);
|
||||
@@ -538,7 +549,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<EditorAttendee[]>(() => {
|
||||
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 +875,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 +1031,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 +1048,7 @@ export function EventModal({
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
onDuplicate(buildDuplicateEventData(event));
|
||||
onDuplicate(event);
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
@@ -1419,17 +1446,16 @@ export function EventModal({
|
||||
<div data-testid="event-time-zone">
|
||||
{showZonePicker ? (
|
||||
<>
|
||||
<label htmlFor={labelFor("time_zone")} className="text-sm font-medium mb-1 block">{tEvent("time_zone")}</label>
|
||||
<select
|
||||
id={labelFor("time_zone")}
|
||||
<div className="text-sm font-medium mb-1">{tEvent("time_zone")}</div>
|
||||
{/* Settings' picker (pim-p2-5): cities with offsets, common zones first, searchable. */}
|
||||
<TimeZonePicker
|
||||
value={fieldZone}
|
||||
onChange={(e) => setTimeZone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{zoneOptions.map((zone) => (
|
||||
<option key={zone} value={zone}>{zone.replace(/_/g, " ")}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setTimeZone}
|
||||
withAuto={false}
|
||||
label={tEvent("time_zone")}
|
||||
className="w-full"
|
||||
defaultOpen
|
||||
/>
|
||||
</>
|
||||
) : fieldZone !== userTimeZone ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
@@ -1441,7 +1467,8 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-primary hover:underline">
|
||||
// A link in both themes: dark's primary is near white and read as a label (pim-p2-19).
|
||||
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-blue-600 dark:text-blue-400 underline-offset-2 hover:underline">
|
||||
{tEvent("time_zone")}
|
||||
</button>
|
||||
)}
|
||||
@@ -1521,18 +1548,13 @@ export function EventModal({
|
||||
{selectableCalendars.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||
<select
|
||||
<CalendarSelect
|
||||
id={labelFor("calendar_select")}
|
||||
calendars={selectableCalendars}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
onChange={setCalendarId}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{selectableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{shownName.calendar(cal)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<ImportStep>("select");
|
||||
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
|
||||
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
const defaultCal = calendars.find((c) => c.isDefault);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
const [calendarId, setCalendarId] = useState<string>(preferredId);
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<CalendarEvent>): string => {
|
||||
if (!event.start) return "";
|
||||
@@ -459,18 +468,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia
|
||||
<label htmlFor={targetId} className="text-sm font-medium mb-1 block">
|
||||
{t("target_calendar")}
|
||||
</label>
|
||||
<select
|
||||
<CalendarSelect
|
||||
id={targetId}
|
||||
calendars={calendars}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
onChange={setCalendarId}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{calendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{shownName.calendar(cal)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="w-4 h-4 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: paint(calendars.find((c) => c.id === draft.calendarId)?.color || "#3b82f6") }}
|
||||
style={{ backgroundColor: paint(selected?.color || "#3b82f6") }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<select
|
||||
<CalendarSelect
|
||||
calendars={calendars}
|
||||
value={draft.calendarId}
|
||||
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
|
||||
onChange={(calendarId) => onChange({ ...draft, calendarId })}
|
||||
aria-label={t("form.calendar_select")}
|
||||
title={calendars.find((c) => c.id === draft.calendarId)?.name}
|
||||
title={selected ? shownName.calendar(selected) : undefined}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
|
||||
>
|
||||
{calendars.map((c) => (
|
||||
<option key={c.id} value={c.id}>{shownName.calendar(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,9 +125,24 @@ function getTaskPriorityIcon(priority: number) {
|
||||
|
||||
type FormatDate = ReturnType<typeof useDisplayDateFormatter>["dateTime"];
|
||||
|
||||
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, 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<CalendarTask, "due" | "progress">): 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<typeof useTranslations>, 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 +199,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 +272,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 (
|
||||
<div
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||
const [progress, setProgress] = useState<CalendarTask["progress"]>(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<AlertOption>(() => {
|
||||
if (!task?.alerts) return "none";
|
||||
const first = Object.values(task.alerts)[0];
|
||||
@@ -346,18 +351,16 @@ export function TaskModal({
|
||||
{calendarOptions.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.calendar")}
|
||||
{tP2("task_list_label")}
|
||||
</label>
|
||||
<select
|
||||
<CalendarSelect
|
||||
id={`${id}-calendar`}
|
||||
kind="task"
|
||||
calendars={calendarOptions}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
onChange={setCalendarId}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>{shownName.calendar(cal)}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={file()} />);
|
||||
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(
|
||||
<ContactImportDialog existingContacts={[]} onImport={async (cards) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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(<ContactMergeDialog cards={[anna, annaOld]} groups={[]} onMerge={vi.fn()} onClose={vi.fn()} />);
|
||||
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(<ContactMergeDialog pickFor={named('0', 'Me')} candidates={candidates} groups={[]} onMerge={vi.fn()} onClose={vi.fn()} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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' }));
|
||||
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog
|
||||
@@ -47,7 +53,12 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
|
||||
}
|
||||
>
|
||||
<div role="radiogroup" aria-label={t("address_books.title")} className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1">
|
||||
{addressBooks.map((book) => {
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key || "own"}>
|
||||
{group.label && (
|
||||
<div className="px-3 pt-2 pb-1 text-xs font-semibold text-muted-foreground truncate" data-testid="address-book-account" title={group.label}>{group.label}</div>
|
||||
)}
|
||||
{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
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<option value="">{t("select_address_book")}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{/* 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)}
|
||||
</option>
|
||||
))}
|
||||
{/* Under each account's address with several (pim-p2-3). A shared book says whose it is, unless its name already does. */}
|
||||
<AccountGroupedOptions
|
||||
items={addressBooks}
|
||||
label={(book) => (book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))}
|
||||
/>
|
||||
</select>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
@@ -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[];
|
||||
@@ -26,6 +27,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 +58,7 @@ export function ContactImportDialog({
|
||||
initialFile,
|
||||
addressBooks = [],
|
||||
defaultAddressBookId,
|
||||
onImported,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const shownName = useCollectionNames();
|
||||
@@ -194,7 +201,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);
|
||||
@@ -354,11 +367,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) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book)}
|
||||
</option>
|
||||
))}
|
||||
<AccountGroupedOptions
|
||||
items={addressBooks}
|
||||
label={(book) => (book.isShared && book.accountName ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book))}
|
||||
/>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -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<ContactCard[] | null>(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 =
|
||||
<dt className="text-muted-foreground">{row.label}</dt>
|
||||
<dd className="min-w-0">
|
||||
{row.values.map((value, i) => (
|
||||
<div key={i} className={cn("break-words", row.label === t("t_notes") && "whitespace-pre-line")}>{value}</div>
|
||||
<div key={i} className={cn("break-words", row.multiline && "whitespace-pre-line")}>{value}</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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. */}
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
{/* Which account it shows, when there are several: the address, which switches. */}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<AccountScopeLabel />
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
<IconButton
|
||||
label={t("new")}
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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<string, ReturnType<typeof vi.fn>>;
|
||||
let client: {
|
||||
getFileNodes: ReturnType<typeof vi.fn>;
|
||||
uploadBlob: ReturnType<typeof vi.fn>;
|
||||
replaceFileNodeBlob: ReturnType<typeof vi.fn>;
|
||||
createFileNode: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function show() {
|
||||
const sessions = [session()];
|
||||
edit.list.mockResolvedValue(sessions);
|
||||
useEditSessions.setState({ sessions });
|
||||
return render(<EditedFilesHost />);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
*/
|
||||
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<Record<string, "changed" | "gone">>({});
|
||||
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 (
|
||||
<div key={session.id} data-testid="edited-file" className="flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -181,6 +223,11 @@ export function EditedFilesHost() {
|
||||
{account && <p className="truncate text-xs text-muted-foreground" data-testid="edited-file-account">{account}</p>}
|
||||
<p className="text-xs text-muted-foreground">{t("changed_body")}</p>
|
||||
{session.lastChance && <p className="text-xs font-medium text-foreground" data-testid="edited-file-last-chance">{t("last_chance")}</p>}
|
||||
{conflict && (
|
||||
<p className="text-xs font-medium text-foreground" role="alert" data-testid="edited-file-conflict">
|
||||
{conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -192,7 +239,21 @@ export function EditedFilesHost() {
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{node && conflict ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "beside")}>
|
||||
{conflict === "changed" ? tFiles("keep_both") : tFiles("save_as_new")}
|
||||
</Button>
|
||||
{conflict === "changed" && (
|
||||
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "replace")}>{tFiles("replace")}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" disabled={busy === session.id} onClick={() => setConflicts(({ [session.id]: _gone, ...rest }) => rest)}>{tFiles("cancel")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{node && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "check")}>{tFiles("save_to_files")}</Button>
|
||||
)}
|
||||
{source.kind === "mail" && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToReply(session, source.emailId, source.ownerAccountId)}>{t("reply")}</Button>
|
||||
)}
|
||||
@@ -200,10 +261,11 @@ export function EditedFilesHost() {
|
||||
<Button size="sm" onClick={() => openDraftWindow({ draftId, localAccountId: session.account, ownerAccountId: source.kind === "draft" ? source.ownerAccountId : undefined })}>{t("open_draft")}</Button>
|
||||
)}
|
||||
{source.kind !== "mail" && !draftId && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
|
||||
<Button size="sm" variant={node ? "outline" : "default"} disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveCopy(session)}>{t("save_copy")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<unknown[]> => []),
|
||||
// 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<Record<string, unknown>>,
|
||||
accountMailboxes: {} as Record<string, Array<Record<string, unknown>>>,
|
||||
};
|
||||
|
||||
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<Record<string, unknown>>,
|
||||
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<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | 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<string, string>) => {
|
||||
const strings: Record<string, string> = {
|
||||
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> = {}): 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(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
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(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
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(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
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(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
await screen.findByText('Weekly sync with Ben');
|
||||
expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -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';
|
||||
@@ -26,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';
|
||||
@@ -39,6 +41,8 @@ import {
|
||||
invitationAnsweredAt,
|
||||
invitationAnsweredKey,
|
||||
findParticipantByEmails,
|
||||
isSeriesInvitation,
|
||||
seriesAnswers,
|
||||
extractMethodFromRawIcs,
|
||||
type EventSummary,
|
||||
type InvitationMethod,
|
||||
@@ -55,6 +59,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 +389,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();
|
||||
@@ -449,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,
|
||||
@@ -460,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);
|
||||
@@ -479,6 +496,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<CancellationRemoval | null>(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<CalendarEvent | null>(null);
|
||||
|
||||
const attachment = findCalendarAttachment(email);
|
||||
|
||||
@@ -577,6 +598,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 +669,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 +719,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 +1098,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{seriesRule && (
|
||||
<span className="flex items-center gap-1.5 min-w-0" data-testid="invitation-repeats">
|
||||
<Repeat className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{tSeries('repeats', { rule: seriesRule })}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.location && (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -1112,9 +1173,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
|
||||
{/* Info / actor messages */}
|
||||
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
|
||||
{showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
|
||||
{declinedInfo && <p className="leading-relaxed" data-testid="invitation-declined-occurrences">{declinedInfo}</p>}
|
||||
{cancellationOutdated && <p className="leading-relaxed">{t('cancel_outdated')}</p>}
|
||||
{actorMessage && <p>{actorMessage}</p>}
|
||||
{actorSummary?.participationComment && (
|
||||
@@ -1206,6 +1268,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t('decline')}
|
||||
</button>
|
||||
{isSeries && (
|
||||
<span className="text-xs text-muted-foreground" data-testid="invitation-answers-series">{tSeries('answers_series')}</span>
|
||||
)}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -146,16 +146,22 @@ describe('a drag from outside', () => {
|
||||
clear();
|
||||
});
|
||||
|
||||
it('on the Files screen, "Upload to <folder>" 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(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
|
||||
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
|
||||
clear();
|
||||
try {
|
||||
render(<FileDropHost />);
|
||||
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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,14 +19,15 @@ const ICONS: Record<FileDropZoneId, ComponentType<{ className?: string }>> = {
|
||||
|
||||
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 ?? "" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={vi.fn()} neighbours={{ previous: 'a.zip', next: 'c.bin', onGo }} />);
|
||||
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(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={vi.fn()} appAction={{ label: 'import_contacts', onRun }} />);
|
||||
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(
|
||||
<FilePreviewModal
|
||||
name="contacts-export.csv"
|
||||
onClose={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
getFileContent={() => 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
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> = {}): 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<React.ComponentProps<typeof FileBrowser>> = {}, 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(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={resources}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onCreateFolder={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
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<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
|
||||
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<HTMLElement>('[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<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -28,11 +28,13 @@ 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";
|
||||
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<void>;
|
||||
onUploadFiles: (files: File[]) => Promise<void>;
|
||||
onUploadFolder: (files: File[]) => Promise<void>;
|
||||
/** 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<void>;
|
||||
onUploadFolder: (files: File[], into?: FileResource) => Promise<void>;
|
||||
onCancelUpload: () => void;
|
||||
/** Delete: into the trash, or for good when in the trash or a shared drive. */
|
||||
onDelete: (name: string) => Promise<void>;
|
||||
@@ -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<void>>(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;
|
||||
@@ -880,6 +913,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 +925,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;
|
||||
@@ -973,6 +1007,12 @@ export function FileBrowser({
|
||||
{canOpenInApp?.(resource) && onOpenInApp && (
|
||||
<ContextMenuItem icon={ExternalLink} label={tUx("open_in_app")} onClick={() => { 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 <ContextMenuItem icon={icon} label={action.label} onClick={() => { closeMenu(); action.onRun(); }} />;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{canKeepOffline(resource) && (
|
||||
@@ -1417,7 +1457,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")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
@@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<PreviewShell name={resource.name} onClose={onClose} onDownload={onDownload} neighbours={neighbours}>
|
||||
<PreviewShell name={resource.name} onClose={onClose} neighbours={neighbours}>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
data-testid="file-no-preview"
|
||||
@@ -41,14 +45,19 @@ export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neig
|
||||
<p className="text-xs text-muted-foreground"><FileSize bytes={resource.contentLength} /></p>
|
||||
<p className="text-sm text-muted-foreground">{tUx("no_preview")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-1">
|
||||
{appAction && (
|
||||
<Button size="sm" onClick={appAction.onRun} data-testid="file-app-action">
|
||||
{appAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{onOpenInApp && (
|
||||
<Button size="sm" onClick={onOpenInApp} className="gap-1.5">
|
||||
<Button size="sm" variant={appAction ? "outline" : "default"} onClick={onOpenInApp} className="gap-1.5">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{tUx("open_in_app")}
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button size="sm" variant={onOpenInApp ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
|
||||
<Button size="sm" variant={onOpenInApp || appAction ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
|
||||
<Download className="w-4 h-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
|
||||
@@ -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) ? (
|
||||
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
) : undefined}
|
||||
actions={(
|
||||
<>
|
||||
{extraAction && (
|
||||
<Button size="sm" variant="outline" className="me-1" data-testid="preview-extra-action" onClick={extraAction.onClick}>
|
||||
{extraAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) && (
|
||||
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
{loading && <Spinner label={t("loading")} className="w-8 h-8 text-white/80" />}
|
||||
|
||||
@@ -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<File | null>(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;
|
||||
}
|
||||
// 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);
|
||||
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 (
|
||||
<div ref={dropRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div
|
||||
ref={dropRootRef}
|
||||
className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}
|
||||
// Files from outside dropped anywhere on the screen go into the folder in view,
|
||||
// as the list itself does (pim-p2-6). The list and a folder's row take theirs first.
|
||||
onDrop={(e) => {
|
||||
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 && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{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 = {}) {
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<UploadConflictDialog question={conflictQuestion} />
|
||||
<ImportEmlDialog file={importEml} onClose={() => setImportEml(null)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -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");
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown />
|
||||
@@ -384,7 +384,7 @@ export function FilesHeader({
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
<>
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />}
|
||||
{narrow && (viewMode === "list"
|
||||
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
|
||||
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
|
||||
|
||||
@@ -13,7 +13,8 @@ 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";
|
||||
import { AccountScopeLabel } from "@/components/layout/account-scope-label";
|
||||
|
||||
const COLLAPSED_KEY = "files-sidebar-collapsed";
|
||||
const RECENT_LIMIT = 8;
|
||||
@@ -286,7 +287,11 @@ export function FilesSidebar({
|
||||
<div className="flex flex-col h-full bg-secondary">
|
||||
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
{/* Which account it shows, when there are several: the address, which switches. */}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<AccountScopeLabel />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<IconButton
|
||||
ref={newButtonRef}
|
||||
@@ -297,7 +302,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");
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
@@ -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 (
|
||||
<Dialog isOpen={!!file} onClose={onClose} title={t("import_to_mail_title")} bodyClassName="px-2" size="sm" testId="import-eml-dialog">
|
||||
<div role="menu" aria-label={t("import_to_mail_title")} className="max-h-[60vh] overflow-y-auto">
|
||||
<MoveFolderList variant="dialog" alwaysFilter noRecent model={model} testIdPrefix="import-eml:" onPick={(id) => void importInto(id)} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -97,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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContactsSidebar } from '@/components/contacts/contacts-sidebar';
|
||||
import { FilesSidebar, type FilesSidebarProps } from '@/components/files/files-sidebar';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// pim-p2-11 (2026-11-04): with two accounts, Contacts and Files changed their whole
|
||||
// list on an account switch with nothing on screen naming the account. Each now shows
|
||||
// the account's address under its title, and a click there switches.
|
||||
|
||||
const switchAccount = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
function renderFiles() {
|
||||
const noop = vi.fn();
|
||||
const props = {
|
||||
currentParentId: null, currentPath: '/', allFolders: [], sharedRoots: [], favorites: [], recentFiles: [],
|
||||
canCreate: false,
|
||||
onNavigate: noop, onOpenRecent: noop, onUploadFiles: noop, onUploadFolder: noop, onNewFolder: noop,
|
||||
onNewTextFile: noop, onToggleFavorite: noop, onDropItems: noop, canShare: () => false, onShare: noop,
|
||||
onCreateIn: noop, onRenameFolder: noop, onDeleteFolder: noop, onRevealRecent: noop,
|
||||
onDownloadRecent: noop, onRemoveRecent: noop,
|
||||
} as unknown as FilesSidebarProps;
|
||||
return render(<FilesSidebar {...props} />);
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
const noop = vi.fn();
|
||||
return render(
|
||||
<ContactsSidebar
|
||||
groups={[]} individuals={[]} addressBooks={[]} activeCategory="all"
|
||||
onSelectCategory={noop} onCreateGroup={noop} onCreateContact={noop}
|
||||
onEditGroup={noop} onDeleteGroup={noop} onComposeGroup={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<null>();
|
||||
|
||||
const active = accounts.find((account) => account.id === activeAccountId);
|
||||
if (accounts.length < 2 || !active) return null;
|
||||
const address = addressOf(active);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="account-scope"
|
||||
data-account-email={active.email || active.username}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.contextMenu.isOpen}
|
||||
aria-label={t("account_scope_label", { address })}
|
||||
title={t("account_scope_label", { address })}
|
||||
onMouseDown={(e) => { if (menu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (menu.contextMenu.isOpen) menu.closeContextMenu();
|
||||
else menu.openContextMenuFrom(e.currentTarget, null, "start");
|
||||
}}
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center gap-0.5 rounded text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate" dir="ltr">{address}</span>
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu} label={t("account_scope_menu")}>
|
||||
{accounts.map((account) => (
|
||||
<ContextMenuItem
|
||||
key={account.id}
|
||||
icon={account.id === active.id ? Check : undefined}
|
||||
label={addressOf(account)}
|
||||
testId="account-scope-option"
|
||||
disabled={account.id === active.id}
|
||||
onClick={() => {
|
||||
menu.closeContextMenu();
|
||||
void switchAccount(account.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -161,3 +161,61 @@ 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(<CalendarManagementSettings />);
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// 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<string, unknown> = { '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(<CalendarManagementSettings />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ 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 { 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';
|
||||
@@ -332,18 +334,23 @@ 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,
|
||||
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.
|
||||
@@ -651,7 +658,14 @@ export function CalendarManagementSettings() {
|
||||
<SettingsSection id="calendars" title={t('title')} description={touch ? t('description_touch') : t('description')}>
|
||||
{renderRowMenu()}
|
||||
<div className="space-y-2">
|
||||
{listedCalendars.map((cal) => {
|
||||
{calendarGroups.map((group) => (
|
||||
<div key={group.key || 'own'} className="space-y-2" data-testid="calendar-settings-group">
|
||||
{group.label && (
|
||||
<h4 className="pt-2 text-xs font-semibold text-muted-foreground truncate" data-testid="calendar-settings-account" title={group.label}>
|
||||
{group.label}
|
||||
</h4>
|
||||
)}
|
||||
{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 +833,8 @@ export function CalendarManagementSettings() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreating ? (
|
||||
<CalendarEditForm
|
||||
|
||||
@@ -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 (<browser zone>)" 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 (
|
||||
<SearchableSelect
|
||||
className="w-64 max-w-full"
|
||||
className={className}
|
||||
defaultOpen={defaultOpen}
|
||||
menuClassName="w-72"
|
||||
value={value}
|
||||
options={options}
|
||||
onSelect={onChange}
|
||||
rank={rank}
|
||||
ariaLabel={`${t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
|
||||
ariaLabel={`${label ?? t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
|
||||
searchLabel={tz('time_zone_search')}
|
||||
noMatchLabel={tz('time_zone_no_match')}
|
||||
renderValue={(option) => (
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 <div>{indicator}</div>;
|
||||
}
|
||||
|
||||
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(<Screen onRefresh={onRefresh} />);
|
||||
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(<Screen onRefresh={vi.fn()} enabled={false} />);
|
||||
expect(mergedDesktopState()['view.refresh']).toBeUndefined();
|
||||
expect(runDesktopCommand('view.refresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,11 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
}, []);
|
||||
|
||||
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<HTMLElement>("[data-menu-row]") ?? element;
|
||||
setContextMenu({ isOpen: true, position, data });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<File[]>([]);
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <run> …" 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<string, unknown> & { id: string; title?: string; calendarIds?: Record<string, boolean>; start?: string };
|
||||
|
||||
async function calendars(c: JmapClient): Promise<Array<{ id: string; name: string; isDefault?: boolean }>> {
|
||||
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<Obj[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string, string[]> }> {
|
||||
return select.evaluate((el) => {
|
||||
const select = el as HTMLSelectElement;
|
||||
const groups: Record<string, string[]> = {};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
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 <run> …", 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<string, unknown> & {
|
||||
id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string;
|
||||
recurrenceOverrides?: Record<string, Record<string, unknown>> | null;
|
||||
participants?: Record<string, Participant> | null;
|
||||
};
|
||||
|
||||
async function defaultCalendar(c: JmapClient): Promise<string> {
|
||||
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<Obj[]> {
|
||||
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<Record<string, Record<string, unknown>>> {
|
||||
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<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function destroyAll(c: JmapClient, prefix: string): Promise<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
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<void> {
|
||||
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 "<title>, <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: 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 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 } } } },
|
||||
}, '0']], [CORE, CAL]);
|
||||
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 });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <run> …", 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<string, unknown> & {
|
||||
id: string;
|
||||
kind?: string;
|
||||
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
|
||||
emails?: Record<string, { address: string }>;
|
||||
addressBookIds?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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<Card[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
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 <run> …" 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<Node[]> {
|
||||
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<Node[]> {
|
||||
return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX));
|
||||
}
|
||||
|
||||
async function cleanUp(c: JmapClient): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <ben@example.org>',
|
||||
'To: dso-ux-pim@example.org',
|
||||
`Subject: ${PREFIX} saved mail`,
|
||||
'Date: Tue, 03 Nov 2026 10:00:00 +0100',
|
||||
'Message-ID: <uxp2-files@example.org>',
|
||||
'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' }]);
|
||||
// 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' }]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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: '<p>Daily</p>', descriptionContentType: 'text/html' }));
|
||||
expect(data).toMatchObject({ description: '<p>Daily</p>', 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): Partial<CalendarEvent> {
|
||||
return {
|
||||
uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }],
|
||||
participants: { me, ben }, recurrenceOverrides: overrides,
|
||||
} as unknown as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
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<CalendarEvent>, ['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<CalendarEvent>)).toBe(false);
|
||||
expect(isSeriesInvitation({ uid: 'x' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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<CalendarEvent> {
|
||||
const newStart = addDays(parseISO(event.start), 1);
|
||||
const data: Partial<CalendarEvent> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<CalendarEvent>,
|
||||
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<string, unknown>);
|
||||
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<CalendarEvent> | null | undefined): boolean {
|
||||
return !!event?.recurrenceRules?.length && !event.recurrenceId;
|
||||
}
|
||||
|
||||
export function findParticipantByEmails(
|
||||
event: Partial<CalendarEvent>,
|
||||
emails: readonly string[]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { canCreateEventsIn } from '@/lib/calendar-editability';
|
||||
import { CALENDAR_COLORS } from '@/lib/shared-calendar-colors';
|
||||
|
||||
/**
|
||||
* Which calendars something new can go into, and how a chooser groups them
|
||||
* when several accounts are signed in (pim-p2-2, p2-3, p2-4, p2-13).
|
||||
*
|
||||
* In the aggregated view every calendar carries the local account it came
|
||||
* from (`localAccountId`); with one account none does, and there is one group.
|
||||
*/
|
||||
|
||||
/** What a chooser needs of a signed-in account. */
|
||||
export interface TargetAccount {
|
||||
id: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** The account's address, as the account menu names it. Never the display name: two accounts of one person share it. */
|
||||
export function accountAddress(account: TargetAccount | undefined): string {
|
||||
return account?.email || account?.username || account?.id || '';
|
||||
}
|
||||
|
||||
/** The local account a calendar belongs to; '' with one account. */
|
||||
export function calendarAccountKey(calendar: Pick<Calendar, 'localAccountId'>): 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<string> } = {},
|
||||
): 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<T extends Pick<Calendar, 'localAccountId'> = 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<T extends Pick<Calendar, 'localAccountId'>>(
|
||||
calendars: T[],
|
||||
accounts: TargetAccount[],
|
||||
activeAccountId: string | null | undefined,
|
||||
): CalendarGroup<T>[] {
|
||||
const byKey = new Map<string, T[]>();
|
||||
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<T>[] = [];
|
||||
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<string, string>,
|
||||
): 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<Calendar, 'localAccountId'>): 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<T extends Calendar>(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<string>();
|
||||
const changed = new Map<string, string>();
|
||||
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 <account>" 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<CalendarEvent, 'localAccountId'>,
|
||||
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<CalendarEvent> {
|
||||
const data: Partial<CalendarEvent> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown };
|
||||
const parsed = JSON.parse(raw) as Partial<EditSource> & { 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 {
|
||||
|
||||
@@ -91,6 +91,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
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<string, string>) =>
|
||||
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'),
|
||||
|
||||
@@ -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<EditSource, { kind: 'node' }>;
|
||||
|
||||
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<SaveBackResult> {
|
||||
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(() => {});
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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> = {}): 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' });
|
||||
});
|
||||
});
|
||||
@@ -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<File> {
|
||||
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' });
|
||||
}
|
||||
+21
-2
@@ -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<DropPlace, 'onFiles' | 'composer' | 'popout' | 'files'>): 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
||||
// 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<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
|
||||
|
||||
|
||||
+10
-7
@@ -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<string, string>();
|
||||
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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<FileWithPath[]>[] = [];
|
||||
|
||||
@@ -5234,6 +5234,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": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال."
|
||||
},
|
||||
@@ -5255,6 +5262,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}\"",
|
||||
@@ -5809,6 +5822,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 الخاصة بك لتوقيع الرسائل وتشفيرها",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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}\"",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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í."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5547,6 +5560,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ů",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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}\"",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5547,6 +5560,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",
|
||||
@@ -7556,6 +7589,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",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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}\"",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5547,6 +5560,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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}\"",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید."
|
||||
},
|
||||
@@ -5255,6 +5262,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}» دعوت کرد",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5547,6 +5560,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
@@ -5119,6 +5119,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": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור."
|
||||
},
|
||||
@@ -5140,6 +5147,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": "מ",
|
||||
@@ -5432,6 +5445,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 שלך עבור חתימה והצפנה של הודעות דוא\"ל",
|
||||
@@ -7557,6 +7590,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}",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5809,6 +5822,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",
|
||||
@@ -7556,6 +7589,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",
|
||||
|
||||
@@ -5234,6 +5234,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."
|
||||
},
|
||||
@@ -5255,6 +5262,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",
|
||||
@@ -5547,6 +5560,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",
|
||||
@@ -7556,6 +7589,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}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user