diff --git a/components/__tests__/keyboard-shortcuts-sections.test.tsx b/components/__tests__/keyboard-shortcuts-sections.test.tsx index 08610f89b..2fabee777 100644 --- a/components/__tests__/keyboard-shortcuts-sections.test.tsx +++ b/components/__tests__/keyboard-shortcuts-sections.test.tsx @@ -9,6 +9,10 @@ vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({ CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }], })); +// Apps without keys get no tab; Contacts and Files are left empty here for that. +vi.mock('@/lib/keyboard-shortcuts/contacts', () => ({ CONTACTS_SECTIONS: [] })); +vi.mock('@/lib/keyboard-shortcuts/files', () => ({ FILES_SECTIONS: [] })); + const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal'); describe('keyboard shortcuts help per app', () => { diff --git a/components/calendar/__tests__/calendar-agenda-view.test.tsx b/components/calendar/__tests__/calendar-agenda-view.test.tsx index fec92d009..00b7e4d5f 100644 --- a/components/calendar/__tests__/calendar-agenda-view.test.tsx +++ b/components/calendar/__tests__/calendar-agenda-view.test.tsx @@ -57,6 +57,14 @@ function renderView(overrides: Partial = {}) { } describe('CalendarAgendaView infinite scroll', () => { + it('shows one empty state with a create button when the window holds no events', () => { + const onCreateEvent = vi.fn(); + renderView({ events: [], onCreateEvent }); + expect(screen.queryByText('events.today_header')).toBeNull(); + screen.getByRole('button', { name: 'events.new_event' }).click(); + expect(onCreateEvent).toHaveBeenCalled(); + }); + beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(2026, 8, 9, 10, 0, 0)); @@ -148,7 +156,7 @@ describe('CalendarAgendaView infinite scroll', () => { }); it('only anchors a "Today" row when today lies inside the loaded window', () => { - renderView(); + renderView({ events: [makeEvent('b', '2026-09-20T09:00:00')] }); expect(screen.getByText('events.today_header')).toBeInTheDocument(); cleanup(); diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx new file mode 100644 index 000000000..7abff772a --- /dev/null +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -0,0 +1,665 @@ +import { render, screen, fireEvent, within, 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.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 })); + +import { CalendarApp } from '../calendar-app'; +import { useRegisteredCommandGroups } from '@/lib/command-registry'; + +// The first render loads the whole page and is slow under load. +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); + useTaskStore.setState({ tasks: [] } as never); + client.getCalendarTasks.mockResolvedValue([]); +} + +function renderApp() { + return render(); +} + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + setUp(); +}); + +describe('CalendarApp keyboard shortcuts', () => { + it('leaves Ctrl, Cmd and Alt combinations to the browser', () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'a', code: 'KeyA', ctrlKey: true }); + fireEvent.keyDown(document.body, { key: 'd', code: 'KeyD', metaKey: true }); + fireEvent.keyDown(document.body, { key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }); + expect(useCalendarStore.getState().viewMode).toBe('week'); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 12)); + }); + + it('switches views by the physical key on any layout', () => { + renderApp(); + // A Cyrillic layout types "ф" on the A key. + fireEvent.keyDown(document.body, { key: 'ф', code: 'KeyA' }); + expect(useCalendarStore.getState().viewMode).toBe('agenda'); + }); + + it('keeps working while the hover preview of an event shows', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + fireEvent.mouseEnter(card); + await screen.findByRole('dialog', { name: 'Standup' }); + fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' }); + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); +}); + + +function PaletteGroups() { + const groups = useRegisteredCommandGroups(); + return
    {groups.flatMap((g) => g.commands.map((c) =>
  • ))}
; +} + +describe('CalendarApp in the command palette', () => { + it('offers Calendar commands while the calendar is open', () => { + render(<>); + const items = screen.getByTestId('palette').textContent ?? ''; + expect(items).toContain('calendar: events.new_event'); + expect(items).toContain('calendar: events.go_to_today'); + expect(items).toContain('calendar: commands.month'); + expect(items).toContain('calendar: toolbar.import'); + expect(items).toContain('calendar: refresh'); + // The view on screen isn't offered again. + expect(items).not.toContain('calendar: commands.week'); + }); + + it('runs a view command', () => { + render(<>); + fireEvent.click(within(screen.getByTestId('palette')).getByText('calendar: commands.day')); + expect(useCalendarStore.getState().viewMode).toBe('day'); + }); +}); + +describe('CalendarApp in the Tasks view', () => { + beforeEach(() => setUp([makeEvent()], 'tasks')); + + it('has no date navigation', () => { + renderApp(); + expect(screen.queryByRole('button', { name: 'nav_prev' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'nav_next' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'views.today' })).toBeNull(); + }); + + it('creates a task with n, not an event', () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + expect(screen.getByRole('heading', { name: 'tasks.create' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'events.create' })).toBeNull(); + }); + + it('creates a task with the phone create button', () => { + media.mobile = true; + media.desktop = false; + renderApp(); + // The floating button, after the drawer's. + fireEvent.click(screen.getAllByRole('button', { name: 'events.new_task' }).at(-1)!); + expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument(); + }); +}); + +const weekly = makeEvent({ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 1 }] as never }); + +function openEditorOn(title = 'Standup') { + fireEvent.click(screen.getAllByRole('button', { name: new RegExp(title) })[0]); + const card = screen.getByRole('dialog', { name: title }); + fireEvent.click(within(card).getByRole('button', { name: 'events.edit' })); +} + +describe('CalendarApp recurring event editor', () => { + beforeEach(() => setUp([weekly])); + + it('goes back to the editor with the changes when the scope question is cancelled', async () => { + renderApp(); + openEditorOn(); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Standup moved' } }); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + const scope = await screen.findByRole('dialog', { name: 'edit_title' }); + fireEvent.click(within(scope).getByRole('button', { name: 'cancel' })); + expect(screen.queryByRole('dialog', { name: 'edit_title' })).toBeNull(); + expect(screen.getByPlaceholderText('form.title')).toHaveValue('Standup moved'); + expect(store.updateEvent).not.toHaveBeenCalled(); + }); +}); + +describe('CalendarApp unsaved editor changes', () => { + it('asks before Escape drops a typed title, and keeps it on Keep editing', async () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } }); + fireEvent.keyDown(screen.getByPlaceholderText('form.title'), { key: 'Escape' }); + const ask = await screen.findByRole('alertdialog', { name: 'discard.title' }); + fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch'); + + fireEvent.click(screen.getByText('form.cancel')); + const again = await screen.findByRole('alertdialog', { name: 'discard.title' }); + fireEvent.click(within(again).getByRole('button', { name: 'discard.confirm' })); + await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull()); + }); + + it('closes an untouched editor without asking', () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.click(screen.getByText('form.cancel')); + expect(screen.queryByPlaceholderText('form.title')).toBeNull(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('asks before a click on an empty slot replaces an editor with changes', async () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } }); + // A single click on a slot opens the quick editor after the double-click window. + fireEvent.click(screen.getAllByRole('gridcell')[3]); + const ask = await screen.findByRole('alertdialog', { name: 'discard.title' }, { timeout: 2000 }); + fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch'); + }); + + it('asks before Cancel drops a changed task', async () => { + setUp([], 'tasks'); + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.change(screen.getByPlaceholderText('tasks.title_placeholder'), { target: { value: 'Buy milk' } }); + fireEvent.click(screen.getByRole('button', { name: 'tasks.cancel' })); + const ask = await screen.findByRole('alertdialog', { name: 'discard.title' }); + fireEvent.click(within(ask).getByRole('button', { name: 'discard.confirm' })); + await waitFor(() => expect(screen.queryByPlaceholderText('tasks.title_placeholder')).toBeNull()); + }); +}); + +function toastTitles(): string[] { + return useToastStore.getState().toasts.map((t) => t.title); +} + +describe('CalendarApp deleting', () => { + it('asks before the right-click menu deletes an event, then says it did', async () => { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'events.delete' })); + const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.event_title' }); + expect(store.deleteEvent).not.toHaveBeenCalled(); + fireEvent.click(within(ask).getByRole('button', { name: 'delete_dialog.confirm' })); + await waitFor(() => expect(store.deleteEvent).toHaveBeenCalledWith(client, 'e1', undefined)); + await waitFor(() => expect(toastTitles()).toContain('notifications.event_deleted')); + }); + + it('asks before the card deletes an event, and Cancel keeps it', async () => { + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' })); + const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.event_title' }); + fireEvent.click(within(ask).getByRole('button', { name: 'cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(store.deleteEvent).not.toHaveBeenCalled(); + }); + + it('asks a series only once: the scope question replaces the confirm', async () => { + setUp([weekly]); + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' })); + expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('asks before deleting a task and says it did', async () => { + setUp([], 'tasks'); + const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never; + useTaskStore.setState({ tasks: [task] } as never); + client.getCalendarTasks.mockResolvedValue([task]); + const deleteCalendarTask = vi.fn().mockResolvedValue(undefined); + Object.assign(client, { deleteCalendarTask }); + renderApp(); + fireEvent.click(await screen.findByText('Buy milk')); + fireEvent.click(screen.getByRole('button', { name: 'tasks.delete' })); + const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.task_title' }); + expect(deleteCalendarTask).not.toHaveBeenCalled(); + fireEvent.click(within(ask).getByRole('button', { name: 'delete_dialog.confirm' })); + await waitFor(() => expect(deleteCalendarTask).toHaveBeenCalledWith('t1')); + await waitFor(() => expect(toastTitles()).toContain('notifications.task_deleted')); + }); +}); + +describe('CalendarApp read-only events', () => { + const readOnlyCalendar = { + ...calendars[0], id: 'ro', name: 'Holidays', isDefault: false, + myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false, mayDelete: false, mayRSVP: false }, + } as unknown as Calendar; + const holiday = makeEvent({ id: 'h1', title: 'Holiday', calendarIds: { ro: true } }); + + beforeEach(() => { + setUp([holiday]); + useCalendarStore.setState({ calendars: [...calendars, readOnlyCalendar], selectedCalendarIds: ['cal', 'ro'] } as never); + }); + + it('offers no Edit or Delete in the right-click menu', async () => { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Holiday/ })[0], { clientX: 50, clientY: 50 }); + expect(await screen.findByRole('menuitem', { name: 'events.duplicate' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'events.edit' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: 'events.delete' })).toBeNull(); + }); + + it('can be neither dragged nor resized', () => { + const { container } = renderApp(); + const card = screen.getAllByRole('button', { name: /Holiday/ })[0]; + expect(card).not.toHaveAttribute('draggable', 'true'); + expect(container.querySelectorAll('[data-resize-handle]')).toHaveLength(0); + }); + + it('keeps an editable event draggable', () => { + setUp([makeEvent()]); + renderApp(); + expect(screen.getAllByRole('button', { name: /Standup/ })[0]).toHaveAttribute('draggable', 'true'); + }); +}); + +function dropOnMonthDay(day: RegExp, event: CalendarEvent) { + const cell = screen.getAllByRole('gridcell').find((c) => day.test(c.getAttribute('aria-label') ?? '')); + if (!cell) throw new Error(`no cell ${day}`); + const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start, duration: event.duration }); + fireEvent.drop(cell, { dataTransfer: { types: ['application/x-calendar-event'], getData: () => json } }); +} + +describe('CalendarApp dragging', () => { + it('asks which occurrences a drag of a series moves', async () => { + setUp([weekly], 'month'); + renderApp(); + dropOnMonthDay(/October 14, 2026/, weekly); + const scope = await screen.findByRole('dialog', { name: 'edit_title' }); + expect(store.updateEvent).not.toHaveBeenCalled(); + fireEvent.click(within(scope).getByRole('button', { name: 'cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'edit_title' })).toBeNull()); + expect(store.updateEvent).not.toHaveBeenCalled(); + }); + + it('moves one event at once and can move it back', async () => { + setUp([makeEvent()], 'month'); + renderApp(); + dropOnMonthDay(/October 14, 2026/, makeEvent()); + await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(1)); + expect(store.updateEvent.mock.calls[0][2]).toMatchObject({ start: '2026-10-14T10:00:00' }); + const moved = await waitFor(() => { + const found = useToastStore.getState().toasts.find((t) => t.title === 'notifications.event_moved'); + expect(found).toBeTruthy(); + return found!; + }); + moved.action!.onClick(); + await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(2)); + expect(store.updateEvent.mock.calls[1][2]).toMatchObject({ start: '2026-10-12T10:00:00', duration: 'PT1H' }); + }); +}); + +describe('CalendarApp task feedback', () => { + const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never; + + it('says when a task couldn\'t be saved and keeps the form open', async () => { + setUp([], 'tasks'); + Object.assign(client, { createCalendarTask: vi.fn().mockRejectedValue(new Error('boom')) }); + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.change(screen.getByPlaceholderText('tasks.title_placeholder'), { target: { value: 'Buy milk' } }); + fireEvent.click(screen.getByRole('button', { name: 'tasks.save' })); + await waitFor(() => expect(toastTitles()).toContain('notifications.task_save_error')); + expect(screen.getByPlaceholderText('tasks.title_placeholder')).toHaveValue('Buy milk'); + }); + + it('says when ticking a task off failed', async () => { + setUp([], 'tasks'); + useTaskStore.setState({ tasks: [task] } as never); + client.getCalendarTasks.mockResolvedValue([task]); + Object.assign(client, { updateCalendarTask: vi.fn().mockRejectedValue(new Error('boom')) }); + renderApp(); + fireEvent.click(await screen.findByRole('button', { name: 'tasks.mark_complete' })); + await waitFor(() => expect(toastTitles()).toContain('notifications.task_update_error')); + }); + + it('adds a quick task to a calendar it may write to', async () => { + setUp([], 'tasks'); + const readOnly = { ...calendars[0], id: 'ro', isDefault: false, myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false } }; + useCalendarStore.setState({ calendars: [readOnly, { ...calendars[0], isDefault: false }], selectedCalendarIds: ['ro', 'cal'] } as never); + const createCalendarTask = vi.fn().mockImplementation(async (data: object) => ({ ...data, id: 't9' })); + Object.assign(client, { createCalendarTask }); + renderApp(); + const input = await screen.findByPlaceholderText('tasks.quick_add_placeholder'); + fireEvent.change(input, { target: { value: 'Call Bob' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(createCalendarTask).toHaveBeenCalled()); + expect(createCalendarTask.mock.calls[0][0]).toMatchObject({ title: 'Call Bob', calendarIds: { cal: true } }); + await waitFor(() => expect(toastTitles()).toContain('notifications.task_created')); + }); + + it('shows completed tasks under All', async () => { + setUp([], 'tasks'); + const done = { ...(task as object), id: 't2', title: 'Pay rent', progress: 'completed' } as never; + useTaskStore.setState({ tasks: [task, done], filter: 'all', showCompleted: false } as never); + client.getCalendarTasks.mockResolvedValue([task, done]); + renderApp(); + expect(await screen.findByText('Pay rent')).toBeInTheDocument(); + expect(screen.queryByText('tasks.show_completed')).toBeNull(); + }); +}); + +describe('CalendarApp creating in the month view', () => { + it('opens the quick editor for an all-day event on a single click', async () => { + setUp([], 'month'); + renderApp(); + const day = screen.getAllByRole('gridcell').find((c) => /October 15, 2026/.test(c.getAttribute('aria-label') ?? ''))!; + fireEvent.click(day); + const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' }); + expect(within(quick).getByText('events.all_day')).toBeInTheDocument(); + fireEvent.change(within(quick).getByLabelText('form.title'), { target: { value: 'Holiday' } }); + fireEvent.click(within(quick).getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + expect(store.createEvent.mock.calls[0][1]).toMatchObject({ + title: 'Holiday', start: '2026-10-15T00:00:00', duration: 'P1D', showWithoutTime: true, + }); + }); + + it('opens the day on a phone tap instead', () => { + media.mobile = true; + media.desktop = false; + setUp([], 'month'); + renderApp(); + const day = screen.getAllByRole('gridcell').find((c) => /October 15, 2026/.test(c.getAttribute('aria-label') ?? ''))!; + fireEvent.click(day); + expect(screen.queryByRole('dialog', { name: 'quick_create.aria_label' })).toBeNull(); + expect(useCalendarStore.getState().viewMode).toBe('day'); + }); +}); + +describe('CalendarApp on touch screens', () => { + it("has Import, Subscribe and Refresh in the phone header's ⋮", async () => { + media.mobile = true; + media.desktop = false; + renderApp(); + // The shell's phone ⋮, labelled by its own namespace (top_bar.more). + // Error toasts of earlier tests add an unread count to its name. + fireEvent.click(screen.getByRole('button', { name: /^more/ })); + expect(await screen.findByRole('menuitem', { name: 'toolbar.import' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'toolbar.subscribe' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'refresh' })).toBeInTheDocument(); + }); + + it('offers the event menu from a ⋮ in the phone event view', async () => { + media.mobile = true; + media.desktop = false; + setUp([makeEvent()], 'day'); + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + const view = await screen.findByRole('dialog', { name: 'Standup' }); + fireEvent.click(within(view).getByRole('button', { name: 'toolbar.more' })); + expect(await screen.findByRole('menuitem', { name: 'events.export_ics' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'events.copy_title' })).toBeInTheDocument(); + }); + + it('opens the event menu on a long press', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + fireEvent.touchStart(card, { touches: [{ clientX: 40, clientY: 40 }] }); + expect(await screen.findByRole('menuitem', { name: 'events.duplicate' }, { timeout: 2000 })).toBeInTheDocument(); + fireEvent.touchEnd(card); + }); +}); + +describe('CalendarApp event menu', () => { + it('opens the form from Edit event, not the summary', async () => { + renderApp(); + fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'events.edit' })); + expect(await screen.findByPlaceholderText('form.title')).toHaveValue('Standup'); + }); +}); + +describe('CalendarApp from the keyboard', () => { + it('gives the month grid one tab stop and moves between days with the arrows', () => { + setUp([], 'month'); + const { container } = renderApp(); + const tabbable = container.querySelectorAll('[data-day-button][tabindex="0"]'); + expect(tabbable).toHaveLength(1); + const today = tabbable[0] as HTMLButtonElement; + today.focus(); + fireEvent.keyDown(today, { key: 'ArrowRight' }); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 13)); + expect(document.activeElement).toBe(container.querySelector('[data-day-button="2026-10-13"]')); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 20)); + // The arrows moved the selection, not the calendar a month on. + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); + + it('moves the focus into an opened event card and back to the event on Escape', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + card.focus(); + fireEvent.click(card, { detail: 0 }); + const popover = await screen.findByRole('dialog', { name: 'Standup' }); + await waitFor(() => expect(popover.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Standup' })).toBeNull()); + expect(document.activeElement).toBe(card); + }); + + it('reaches a task with Tab and opens it', async () => { + setUp([], 'tasks'); + const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never; + useTaskStore.setState({ tasks: [task] } as never); + client.getCalendarTasks.mockResolvedValue([task]); + renderApp(); + fireEvent.click(await screen.findByRole('button', { name: /Buy milk/ })); + expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument(); + }); +}); + +describe('CalendarApp control names', () => { + it('names close buttons Close and tells the arrows apart', async () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument(); + // Cancel is the text button in the footer, not the X. + expect(screen.getAllByRole('button', { name: 'form.cancel' })).toHaveLength(1); + // The mini calendar's arrows step months, the toolbar's the view. + expect(screen.getByRole('button', { name: 'mini_prev_month' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'nav_prev' })).toHaveLength(1); + expect(screen.getByRole('button', { name: 'nav_hide_sidebar' })).toBeInTheDocument(); + }); + + it('names the task editor fields and its close button', async () => { + setUp([], 'tasks'); + renderApp(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument(); + expect(screen.getByLabelText('tasks.due_date')).toHaveAttribute('type', 'date'); + expect(screen.getByLabelText('tasks.priority').tagName).toBe('SELECT'); + expect(screen.getByLabelText('tasks.quick_add_placeholder')).toBeInTheDocument(); + }); + + it('reads an all-day event as all day, not as midnight to 23:00', () => { + setUp([makeEvent({ title: 'Trip', showWithoutTime: true, start: '2026-10-12T00:00:00', duration: 'P1D' })], 'month'); + renderApp(); + expect(screen.getAllByRole('button', { name: /Trip/ })[0].getAttribute('aria-label')).toContain('events.all_day'); + }); +}); + +describe('CalendarApp in the demo', () => { + it('leaves Import and Subscribe out, which need a mail server', async () => { + config.demoMode = true; + try { + renderApp(); + fireEvent.click(screen.getByRole('button', { name: 'toolbar.more' })); + expect(await screen.findByRole('menuitem', { name: 'refresh' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'toolbar.import' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: 'toolbar.subscribe' })).toBeNull(); + } finally { + config.demoMode = false; + } + }); +}); + +describe('CalendarApp view menu', () => { + it('opens as a menu the keyboard can use and switches the view', async () => { + renderApp(); + fireEvent.click(screen.getByTestId('calendar-view-menu'), { detail: 0 }); + const day = await screen.findByRole('menuitemradio', { name: /views\.day/ }); + await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('menuitemradio')); + fireEvent.click(day); + expect(useCalendarStore.getState().viewMode).toBe('day'); + }); +}); + +describe('CalendarApp long names', () => { + it('shows the full name of a cut-off calendar on hover', () => { + const long = { ...calendars[0], name: 'Stalwart Calendar (usera@internal.bulwarkmail.org)' }; + useCalendarStore.setState({ calendars: [long] } as never); + renderApp(); + expect(screen.getByTestId('calendar-item').querySelector('[title]')).toHaveAttribute('title', long.name); + }); +}); + +describe('CalendarApp after saving', () => { + it('brings a new event into view when it starts outside the hours shown', async () => { + useSettingsStore.setState({ calendarLimitHours: true, calendarDayStartHour: 8, calendarDayEndHour: 18 } as never); + try { + renderApp(); + expect(screen.queryAllByRole('gridcell', { name: /01:00$/ })).toHaveLength(0); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Night train' } }); + fireEvent.change(document.querySelector('input[type="time"]')!, { target: { value: '01:00' } }); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(store.createEvent).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByRole('gridcell', { name: /01:00$/ }).length).toBeGreaterThan(0)); + } finally { + useSettingsStore.setState({ calendarLimitHours: false } as never); + } + }); +}); + +describe('CalendarApp empty agenda', () => { + it('offers to create the first event', () => { + setUp([], 'agenda'); + renderApp(); + fireEvent.click(screen.getByRole('button', { name: 'events.new_event' })); + expect(screen.getByRole('heading', { name: 'events.create' })).toBeInTheDocument(); + }); +}); + +describe('CalendarApp selected event', () => { + it('rings the event whose card is open', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + expect(card.style.boxShadow).not.toContain('--color-primary'); + fireEvent.click(card); + await screen.findByRole('dialog', { name: 'Standup' }); + expect(screen.getAllByRole('button', { name: /Standup/ })[0].style.boxShadow).toContain('var(--color-primary)'); + }); +}); + +describe('CalendarApp day view tasks', () => { + it('draws a due task with the same chip as the week and month grids', async () => { + setUp([], 'day'); + const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2026-10-12', showWithoutTime: true } as never; + useSettingsStore.setState({ showTasksOnCalendar: true } as never); + useTaskStore.setState({ tasks: [task] } as never); + client.getCalendarTasks.mockResolvedValue([task]); + try { + renderApp(); + const chip = await waitFor(() => { + const found = document.querySelector('[data-calendar-task="t1"]'); + expect(found).not.toBeNull(); + return found as HTMLElement; + }); + // The shared chip: the title is its own button that opens the task. + fireEvent.click(within(chip).getByRole('button', { name: 'Buy milk' })); + expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument(); + } finally { + useSettingsStore.setState({ showTasksOnCalendar: false } as never); + } + }); +}); diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx new file mode 100644 index 000000000..bc6b39924 --- /dev/null +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Calendar } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The event editor opened with the focus on its close button, so typing +// went nowhere; the import, subscription and new-calendar dialogs were +// hand-rolled: Escape only worked from some places and no focus handling. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +import { EventModal } from '../event-modal'; +import { ICalImportModal } from '../ical-import-modal'; +import { ICalSubscriptionModal } from '../ical-subscription-modal'; +import { CreateCalendarModal } from '../create-calendar-modal'; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }, +}] as unknown as Calendar[]; +const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient; + +describe('calendar dialogs', () => { + it('opens a new event with the cursor in Title', () => { + render(); + expect(document.activeElement).toBe(screen.getByPlaceholderText('form.title')); + }); + + it('closes the import dialog with Escape, wherever the focus is', () => { + const onClose = vi.fn(); + render(); + const dialog = screen.getByRole('dialog', { name: 'title' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the subscription dialog in the address field, with a named close button', () => { + const onClose = vi.fn(); + render(); + expect(document.activeElement).toBe(screen.getByLabelText('url_label')); + fireEvent.click(screen.getByRole('button', { name: 'close' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the new-calendar dialog in the name field and keeps typed input on an outside click', () => { + const onClose = vi.fn(); + render(); + const name = screen.getByLabelText('name'); + expect(document.activeElement).toBe(name); + fireEvent.change(name, { target: { value: 'Gym' } }); + const backdrop = screen.getByRole('dialog').parentElement!; + fireEvent.mouseDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe('import dialog from the keyboard', () => { + it('lets the keyboard reach the file drop zone', () => { + render(); + expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument(); + }); +}); + +describe('import preview', () => { + it('names a repeating event\'s rule in the app language', async () => { + const weekly = { title: 'Gym', start: '2026-10-12T18:00:00', duration: 'PT1H', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 1 }] }; + const parsing = { + getAccountId: () => 'acct', + getCalendarsAccountId: () => 'acct', + uploadBlob: vi.fn().mockResolvedValue({ blobId: 'b1' }), + parseCalendarEvents: vi.fn().mockResolvedValue([weekly]), + } as unknown as IJMAPClient; + render(); + const file = new File(['BEGIN:VCALENDAR'], 'gym.ics', { type: 'text/calendar' }); + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } }); + expect(await screen.findByText('Gym')).toBeInTheDocument(); + expect(screen.queryByText('weekly')).toBeNull(); + expect(screen.getByText('recurrence.weekly')).toBeInTheDocument(); + }); +}); + +describe('invitation view', () => { + it('shows an invitation with the same details as any event: maps link, meeting link, menu and answers', () => { + const shared = [{ + id: 'team', name: 'Team', color: '#123456', + myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: true, mayDelete: false }, + }] as unknown as Calendar[]; + const invite = { + id: 'i1', uid: 'u1', '@type': 'Event', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', + showWithoutTime: false, calendarIds: { team: true }, + description: 'Join: https://teams.microsoft.com/l/meetup-join/abc', + locations: { l: { '@type': 'Location', name: 'Main St 1, Berlin' } }, + organizerCalendarAddress: 'mailto:boss@example.org', + participants: { + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', roles: { owner: true }, participationStatus: 'accepted' }, + me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as unknown as import('@/lib/jmap/types').CalendarEvent; + render( + , + ); + expect(screen.getByText('participants.invited_by')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Main St 1, Berlin' }).getAttribute('href')).toMatch(/maps/); + expect(screen.getByRole('link', { name: 'teams.microsoft.com' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'toolbar.more' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'participants.accept' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /events\.edit/ })).toBeNull(); + }); +}); diff --git a/components/calendar/__tests__/calendar-display-range.test.tsx b/components/calendar/__tests__/calendar-display-range.test.tsx index 08b0c5934..2135445a7 100644 --- a/components/calendar/__tests__/calendar-display-range.test.tsx +++ b/components/calendar/__tests__/calendar-display-range.test.tsx @@ -127,7 +127,7 @@ describe('calendar display hours and working days (#1164)', () => { expect(eventBlock('weekend')).toBeNull(); const trip = screen.getByText('Event trip').closest('.absolute')!; // Column 5 of 6, two columns wide (jsdom rounds the percentages) - expect(trip.style.left).toBe('calc(66.6667% + 1px)'); + expect(trip.style.insetInlineStart).toBe('calc(66.6667% + 1px)'); expect(trip.style.width).toBe('calc(33.3333% - 2px)'); }); diff --git a/components/calendar/__tests__/calendar-month-view-rtl.test.tsx b/components/calendar/__tests__/calendar-month-view-rtl.test.tsx new file mode 100644 index 000000000..b98840097 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-rtl.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// In Arabic the day grid mirrors, but the event chips laid over it were +// placed with `left`, so Sunday's event was drawn under Monday. And the +// phone's weekday headers were the first two letters of each name ("اث"). +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => ({ 'days.mon': 'الاثنين' } as Record)[key] ?? key, + useLocale: () => 'ar', + useFormatter: () => ({ + dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('ar', options).format(d), + }), +})); + +import { CalendarMonthView } from '../calendar-month-view'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; +const birthday = { + id: 'e1', uid: 'u1', title: 'Birthday', start: '2026-10-07T00:00:00', duration: 'P1D', showWithoutTime: true, + calendarIds: { 'cal-1': true }, status: 'confirmed', +} as unknown as CalendarEvent; + +function renderMonth(isMobile = false) { + return render( + , + ); +} + +describe('CalendarMonthView in a right-to-left language', () => { + it('places event chips from the start edge, so they follow the mirrored grid', () => { + renderMonth(); + const chip = screen.getByRole('button', { name: /Birthday/ }).parentElement as HTMLElement; + // Wednesday is the third column of a week starting on Monday. + expect(chip.style.insetInlineStart).toMatch(/^calc\(28\.57\d*% \+ 1px\)$/); + expect(chip.style.left).toBe(''); + }); + + it('heads the phone grid with the language\'s short weekday names', () => { + renderMonth(true); + const monday = screen.getAllByRole('columnheader')[0]; + expect(monday.textContent).toBe(new Intl.DateTimeFormat('ar', { weekday: 'narrow' }).format(new Date(2026, 8, 28))); + expect(monday.textContent).not.toBe('ال'); + }); +}); diff --git a/components/calendar/__tests__/calendar-month-view-tasks.test.tsx b/components/calendar/__tests__/calendar-month-view-tasks.test.tsx index d5447eb9d..97f67dfc0 100644 --- a/components/calendar/__tests__/calendar-month-view-tasks.test.tsx +++ b/components/calendar/__tests__/calendar-month-view-tasks.test.tsx @@ -97,10 +97,10 @@ describe('CalendarMonthView tasks', () => { // already has an event in row 0; Friday has no events. // (jsdom rounds the calc() percentage.) const thu = chipSlot('thu'); - expect(thu.style.left).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/); + expect(thu.style.insetInlineStart).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/); expect(thu.style.top).toBe('22px'); const fri = chipSlot('fri'); - expect(fri.style.left).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/); + expect(fri.style.insetInlineStart).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/); expect(fri.style.top).toBe('0px'); }); @@ -108,17 +108,19 @@ describe('CalendarMonthView tasks', () => { const onOpenDay = vi.fn(); renderView({ events: [makeAllDayEvent('e1', '2026-09-10')], - tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')), + tasks: ['a', 'b', 'c', 'd'].map((id) => makeTask(id, '2026-09-10')), onOpenDay, }); const row = document.querySelector('[data-week="2026-09-07"]'); - // jsdom measures no viewport, so rows keep the 100px minimum: room for - // two 22px rows under the 30 + 4 offset, the second taken by the link. - expect(row?.style.minHeight).toBe('100px'); + // jsdom measures no viewport, so rows keep the 130px minimum: room for + // four 22px rows under the 30 + 4 offset, the last taken by the link - + // three entries, not one, on a short desktop window. + expect(row?.style.minHeight).toBe('130px'); expect(screen.getByText('Event e1')).toBeInTheDocument(); - expect(document.querySelector('[data-calendar-task="a"]')).toBeNull(); + expect(document.querySelector('[data-calendar-task="b"]')).not.toBeNull(); + expect(document.querySelector('[data-calendar-task="c"]')).toBeNull(); const more = screen.getByRole('button', { name: 'events.more' }); - expect(more.parentElement?.style.top).toBe('22px'); + expect(more.parentElement?.style.top).toBe('66px'); fireEvent.click(more); expect(onOpenDay).toHaveBeenCalledWith(new Date(2026, 8, 10)); }); diff --git a/components/calendar/__tests__/calendar-sidebar-touch.test.tsx b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx new file mode 100644 index 000000000..befa7469d --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar } from '@/lib/jmap/types'; + +// On phones and tablets a calendar's actions (share, colour, default, +// delete) were behind a ⋮ that only showed on hover, and nothing opened on a +// long press. + +const work = { + id: 'work', name: 'Work', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, + isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true }, +} as unknown as Calendar; +const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient; + +describe('CalendarSidebarPanel on touch screens', () => { + it('keeps the row ⋮ visible on touch and keyboard focus', () => { + render( {}} client={client} />); + const more = screen.getByRole('button', { name: 'calendar_options' }); + expect(more.className).toContain('pointer-coarse:opacity-100'); + expect(more.className).toContain('group-focus-within/cal:opacity-100'); + }); + + it('opens a calendar\'s menu on a long press', async () => { + render( {}} onDeleteCalendar={() => {}} client={client} />); + fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] }); + expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/calendar-sidebar-unsubscribe.test.tsx b/components/calendar/__tests__/calendar-sidebar-unsubscribe.test.tsx new file mode 100644 index 000000000..6d2ec0f25 --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-unsubscribe.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { useCalendarStore } from '@/stores/calendar-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar } from '@/lib/jmap/types'; + +// Unsubscribing removes the feed's calendar and every event in it. Settings +// asked first, the sidebar's menu removed it at once. + +const feed = { + id: 'feed', name: 'Holidays', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, + isDefault: false, includeInAvailability: 'none', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }, +} as unknown as Calendar; + +describe('CalendarSidebarPanel unsubscribe', () => { + it('asks before it removes a subscribed calendar', async () => { + const removeICalSubscription = vi.fn().mockResolvedValue(undefined); + useCalendarStore.setState({ + icalSubscriptions: [{ id: 'sub', url: 'https://example.com/h.ics', calendarId: 'feed', name: 'Holidays', color: '#3b82f6', refreshInterval: 60, lastRefreshed: null }], + isSubscriptionCalendar: (id: string) => id === 'feed', + removeICalSubscription, + } as never); + const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient; + render( {}} client={client} />); + + fireEvent.contextMenu(screen.getByTestId('calendar-item'), { clientX: 20, clientY: 20 }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'unsubscribe' })); + const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.subscription_title' }); + expect(removeICalSubscription).not.toHaveBeenCalled(); + + fireEvent.click(within(ask).getByRole('button', { name: 'unsubscribe' })); + await waitFor(() => expect(removeICalSubscription).toHaveBeenCalledWith(client, 'sub')); + }); +}); diff --git a/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx b/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx index 1fc4014fd..4b48bc82b 100644 --- a/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx +++ b/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx @@ -107,7 +107,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => { ], }); - const toggle = screen.getByRole('button', { name: /all-day-expand/i }); + const toggle = screen.getByRole('button', { name: /events.show_more|events.show_less/ }); expect(toggle).toBeInTheDocument(); expect(toggle).toHaveAttribute('aria-expanded', 'false'); @@ -137,7 +137,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => { ], }); - expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /events.show_more|events.show_less/ })).toBeNull(); expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull(); }); @@ -156,7 +156,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => { const allDayGrid = document.querySelector('[data-testid="all-day-grid"]'); expect(allDayGrid?.style.height).toBe('28px'); - expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /events.show_more|events.show_less/ })).toBeNull(); }); it('resizes the all-day area when scrolling to a crowded week (#1293)', async () => { @@ -183,6 +183,6 @@ describe('CalendarWeekView all-day and tasks height cap', () => { const allDayGrid = await screen.findByTestId('all-day-grid'); expect(allDayGrid.style.height).toBe('76px'); - expect(screen.getByRole('button', { name: /all-day-expand/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /events.show_more|events.show_less/ })).toBeInTheDocument(); }); }); diff --git a/components/calendar/__tests__/rsvp-buttons.test.tsx b/components/calendar/__tests__/rsvp-buttons.test.tsx new file mode 100644 index 000000000..2e4305e3f --- /dev/null +++ b/components/calendar/__tests__/rsvp-buttons.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ParticipantStatusBadge, RsvpButtons } from '../rsvp-buttons'; + +// "Tentative" (#eab308) and "Accepted" (#22c55e) text on white measured +// about 1.9:1 and 2.3:1, and the chosen Tentative was white on yellow. Text +// uses the darker status shades; a chosen answer gets dark text. + +describe('participation status colours', () => { + it('writes answers in readable status shades, not the light fills', () => { + render(); + const maybe = screen.getByRole('button', { name: 'participants.maybe' }); + expect(maybe.className).toContain('text-amber-600'); + expect(maybe.className).not.toMatch(/\btext-warning\b/); + expect(screen.getByRole('button', { name: 'participants.accept' }).className).toContain('text-green-700'); + }); + + it('puts dark text on the chosen yellow answer', () => { + render(); + const maybe = screen.getByRole('button', { name: 'participants.maybe' }); + expect(maybe).toHaveAttribute('aria-pressed', 'true'); + expect(maybe.className).toContain('text-neutral-950'); + expect(maybe.className).not.toContain('text-warning-foreground'); + }); + + it('labels the buttons with what they do, not the state', () => { + render(); + expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['participants.accept', 'participants.maybe', 'participants.decline']); + }); + + it('colours a participant\'s answer the same way', () => { + render(); + expect(screen.getByText('participants.accepted').className).toContain('text-green-700'); + }); +}); diff --git a/components/calendar/__tests__/task-list-dates.test.tsx b/components/calendar/__tests__/task-list-dates.test.tsx new file mode 100644 index 000000000..ef2738e39 --- /dev/null +++ b/components/calendar/__tests__/task-list-dates.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// Due dates were formatted with date-fns' English month names, so a German +// list read "Oct 30" next to "Überfällig". They follow the app's language. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'de', + useFormatter: () => ({ + dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('de-DE', options).format(d), + }), +})); + +import { TaskListView } from '../task-list-view'; + +const calendars = [{ id: 'cal', name: 'Privat', color: '#123456' }] as unknown as Calendar[]; + +describe('TaskListView due dates', () => { + it('writes the due date in the app language', () => { + const task = { id: 't', title: 'Steuer', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2031-10-30', showWithoutTime: true } as unknown as CalendarTask; + render( {}} onToggleComplete={() => {}} />); + expect(screen.getByText('30. Okt.')).toBeInTheDocument(); + expect(screen.queryByText('Oct 30')).toBeNull(); + }); +}); diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx index 473f460e5..eabfe086f 100644 --- a/components/calendar/__tests__/task-plugin-surfaces.test.tsx +++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx @@ -69,9 +69,7 @@ describe('task-detail-section', () => { describe('task-list-actions', () => { const toolbar = { filter: 'all' as const, - showCompleted: false, onFilterChange: vi.fn(), - onShowCompletedChange: vi.fn(), onCreateTask: vi.fn(), }; @@ -104,7 +102,8 @@ describe('task-list-actions', () => { makeTask('other-cal', { calendarIds: { 'cal-2': true } }), makeTask('soon', { due: '2030-01-01' }), ]; - expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']); - expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']); + // All is all of them: completed ones too, at the end. + expect(filterTasksForList(tasks, ['cal-1'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']); + expect(filterTasksForList(tasks, ['cal-1'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']); }); }); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index b24857449..850321a93 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -4,7 +4,9 @@ import { useMemo, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { format, isTomorrow, startOfDay } from "date-fns"; -import { MapPin, Users } from "@/components/icons"; +import { CalendarDays, MapPin, Users } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; import { cn } from "@/lib/utils"; import { getEventColor } from "./event-card"; import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -24,6 +26,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps { timeFormat?: "12h" | "24h"; /** The user's calendar addresses, to mark events they declined (#1110). */ currentUserEmails?: string[]; + /** Offered on an empty agenda: make the first event. */ + onCreateEvent?: () => void; } interface DayGroup { @@ -48,6 +52,7 @@ export function CalendarAgendaView({ onContextMenuEvent, timeFormat = "24h", currentUserEmails, + onCreateEvent, }: CalendarAgendaViewProps) { const t = useTranslations("calendar"); // Grid days / event dates are display dates (local fields = wall-clock in @@ -63,6 +68,7 @@ export function CalendarAgendaView({ const scrollContainerRef = useRef(null); const bottomSentinelRef = useRef(null); + const hasEvents = events.length > 0; const grouped = useMemo(() => { const sorted = [...events].sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime() @@ -177,13 +183,18 @@ export function CalendarAgendaView({ )} - {grouped.length === 0 && !isLoading && ( -
- {t("events.no_events")} -
+ {/* Nothing in the loaded window: one empty state with the way out, + not a lone "Today" row. */} + {!hasEvents && !isLoading && ( + {t("events.new_event")} : undefined} + /> )} - {grouped.map((group, index) => { + {hasEvents && grouped.map((group, index) => { const today = isDisplayToday(group.date); const relativeLabel = today ? t("events.today_header") @@ -264,13 +275,13 @@ export function CalendarAgendaView({ />
-
+
{ev.title || t("events.no_title")}
{(meta || getParticipantCount(ev) > 0) && (
{locationName && } - {meta && {meta}} + {meta && {meta}} {getParticipantCount(ev) > 0 && ( diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index d91420215..35c21002d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -3,13 +3,13 @@ import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; -import { Plus } from "@/components/icons"; +import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw } from "@/components/icons"; import { addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, format, parseISO, } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; -import { isCalendarViewMode } from "@/stores/calendar-store"; +import { isCalendarViewMode, type CalendarViewMode } from "@/stores/calendar-store"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -20,6 +20,7 @@ import { toast } from "@/stores/toast-store"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails"; import { Button } from "@/components/ui/button"; +import { PaneSpinner, Spinner } from "@/components/ui/spinner"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; @@ -59,9 +60,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 { getEventStartDate } from "@/lib/calendar-utils"; +import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; -import { canCreateEventsIn } from "@/lib/calendar-editability"; +import { canCreateEventsIn, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; @@ -69,11 +70,14 @@ import { cn, generateUUID } from "@/lib/utils"; import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Dialog } from "@/components/ui/dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header"; -import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants"; +import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getParticipantList } from "@/lib/calendar-participants"; +import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions"; +import type { RevealTime } from "@/hooks/use-display-hours"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors"; import { debug } from "@/lib/debug"; @@ -96,10 +100,17 @@ import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { calendarHooks } from "@/lib/plugin-hooks"; import { eventToView } from "@/lib/plugin-projection"; +import { calendarShortcutAction } from "@/lib/calendar-shortcuts"; +import { isEditableEventTarget } from "@/lib/keyboard"; +import { isContextMenuOpen } from "@/components/ui/context-menu"; +import { useRegisterCommands } from "@/lib/command-registry"; +import { useConfig } from "@/hooks/use-config"; +// `fromEditor`: asked on the way out of the event editor, which stays open +// until a scope is picked, so Cancel goes back to it with the changes. type PendingScopeAction = - | { type: "edit"; event: CalendarEvent; updates: Partial; editor?: EventEditorState; sendScheduling?: boolean } - | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean } + | { type: "edit"; event: CalendarEvent; updates: Partial; editor?: EventEditorState; sendScheduling?: boolean; fromEditor?: boolean } + | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean; fromEditor?: boolean } | { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] }; function isRecurringEvent(event: CalendarEvent): boolean { @@ -121,6 +132,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const tSidebar = useTranslations("sidebar"); useDocumentTitle(tSidebar("calendar")); const tWebcalAction = useTranslations("calendar.webcal_action"); + const { demoMode } = useConfig(); const tDeepLink = useTranslations("deep_link"); const isMobile = useIsMobile(); const isDesktop = useIsDesktop(); @@ -218,6 +230,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); + // Whether the open event or task editor holds unsaved changes (R5). + const eventEditorDirtyRef = useRef(false); + const taskEditorDirtyRef = useRef(false); + const setEventEditorDirty = useCallback((dirty: boolean) => { eventEditorDirtyRef.current = dirty; }, []); + const setTaskEditorDirty = useCallback((dirty: boolean) => { taskEditorDirtyRef.current = dirty; }, []); const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null); const [swipeKey, setSwipeKey] = useState(0); const hasFetched = useRef(false); @@ -639,7 +656,31 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = jumpTo(date); }, [jumpTo]); - const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => { + // Runs `then` at once, or, while an editor holds unsaved changes, after + // the user agreed to drop them: a click elsewhere in the calendar must + // not throw away what was typed (R5). + const discardingRef = useRef(false); + const whenEditorsDiscarded = useCallback((then: () => void) => { + const dirty = eventEditorDirtyRef.current || taskEditorDirtyRef.current; + if (!dirty) { then(); return; } + if (discardingRef.current) return; + discardingRef.current = true; + void confirmAction({ + title: t("discard.title"), + message: t("discard.message"), + confirmText: t("discard.confirm"), + cancelText: t("discard.keep"), + variant: "destructive", + }).then((ok) => { + discardingRef.current = false; + if (!ok) return; + eventEditorDirtyRef.current = false; + taskEditorDirtyRef.current = false; + then(); + }); + }, [confirmAction, t]); + + const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => whenEditorsDiscarded(() => { setQuickDraft(null); setDefaultModalTitle(undefined); setEditEvent(null); @@ -651,16 +692,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setShowTaskModal(false); setEditTask(null); setShowEventModal(true); - }, [selectedDate, setSelectedDate]); + }), [selectedDate, setSelectedDate, whenEditorsDiscarded]); - const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => { + const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => { setModalStartsInEdit(startInForm); setEditEvent(event); setDefaultModalDate(undefined); setShowTaskModal(false); setEditTask(null); setShowEventModal(true); - }, []); + }), [whenEditorsDiscarded]); // The event and task panels share the space beside the grid; now that // tasks open from the month, week and day views (#1107), opening one @@ -673,40 +714,68 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDefaultModalAllDay(false); }, []); - const openCreateTaskModal = useCallback(() => { + const openCreateTaskModal = useCallback(() => whenEditorsDiscarded(() => { closeEventPanelForTask(); setEditTask(null); setShowTaskModal(true); - }, [closeEventPanelForTask]); + }), [closeEventPanelForTask, whenEditorsDiscarded]); - const openEditTaskModal = useCallback((task: CalendarTask) => { + const openEditTaskModal = useCallback((task: CalendarTask) => whenEditorsDiscarded(() => { closeEventPanelForTask(); setEditTask(task); setShowTaskModal(true); - }, [closeEventPanelForTask]); + }), [closeEventPanelForTask, whenEditorsDiscarded]); const handleToggleTaskComplete = useCallback((task: CalendarTask) => { - if (client) toggleTaskCompleteFn(client, task); - }, [client, toggleTaskCompleteFn]); + if (!client) return; + // The checkbox shows the result; only a failure needs saying. + toggleTaskCompleteFn(client, task).catch((error: unknown) => { + toast.error(describeError(error, t("notifications.task_update_error"))); + }); + }, [client, toggleTaskCompleteFn, describeError, t]); - // false: a plugin stopped it and said why, so the task form stays open. + // false: a plugin stopped it and said why, or the save failed: the task + // form stays open with what was typed. const handleSaveTask = useCallback(async (data: Partial) => { if (!client) return false; - const saved = editTask - ? await taskStore.updateTask(client, editTask.id, data) - : (await taskStore.createTask(client, data)) !== null; + let saved: boolean; + try { + saved = editTask + ? await taskStore.updateTask(client, editTask.id, data) + : (await taskStore.createTask(client, data)) !== null; + } catch (error) { + toast.error(describeError(error, t("notifications.task_save_error"))); + return false; + } if (!saved) return false; + toast.success(t(editTask ? "notifications.task_updated" : "notifications.task_created")); setShowTaskModal(false); setEditTask(null); return true; - }, [client, editTask, taskStore]); + }, [client, editTask, taskStore, describeError, t]); + // A task delete can't be taken back: ask first (R3), then say how it went. const handleDeleteTask = useCallback(async (id: string) => { if (!client) return; - if (!(await taskStore.deleteTask(client, id))) return; + const task = taskStore.tasks.find((x) => x.id === id); + const ok = await confirmAction({ + title: t("delete_dialog.task_title"), + message: t("delete_dialog.task_message", { title: task?.title || t("tasks.no_title") }), + confirmText: t("delete_dialog.confirm"), + variant: "destructive", + }); + if (!ok) return; + try { + // false: a plugin stopped it and said why. + if (!(await taskStore.deleteTask(client, id))) return; + } catch (error) { + toast.error(describeError(error, t("notifications.task_delete_error"))); + return; + } + toast.success(t("notifications.task_deleted")); setShowTaskModal(false); setEditTask(null); - }, [client, taskStore]); + }, [client, taskStore, confirmAction, describeError, t]); const hoverTimerRef = useRef | null>(null); @@ -740,6 +809,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { contextMenu: eventContextMenu, openContextMenu: openEventContextMenu, + openContextMenuFrom: openEventContextMenuFrom, closeContextMenu: closeEventContextMenu, menuRef: eventContextMenuRef, } = useContextMenu(); @@ -933,19 +1003,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh calendar data via JMAP instead of reloading the page. + const refreshCalendar = useCallback(async () => { + if (!client) return; + const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId + ? fetchAllAccountsCalendarsFn(accountClients, activeAccountId) + : fetchCalendars(client); + await Promise.all([ + calendarRefresh, + refetchCurrentRange(), + refreshAllSubscriptions(client), + ]); + }, [client, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsCalendarsFn, fetchCalendars, refetchCurrentRange, refreshAllSubscriptions]); const { indicator: refreshIndicator } = useRefreshGesture({ enabled: isAuthenticated && !!client, - onRefresh: async () => { - if (!client) return; - const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId - ? fetchAllAccountsCalendarsFn(accountClients, activeAccountId) - : fetchCalendars(client); - await Promise.all([ - calendarRefresh, - refetchCurrentRange(), - refreshAllSubscriptions(client), - ]); - }, + onRefresh: refreshCalendar, }); // Changes made elsewhere (another device, another session, an invitation) @@ -961,6 +1032,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) void fetchTasksFn(client); }, { enabled: isAuthenticated && !!client, includeBackground: multiAccountEnabled }); + // The time a saved event starts at, for the week and day grids to show it + // even when it is outside the hours on screen. + const [revealAt, setRevealAt] = useState(null); const focusCalendarOnEvent = useCallback((event: Pick, "start" | "utcStart" | "showWithoutTime">) => { if (!event.start) { return; @@ -976,6 +1050,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } jumpTo(eventDate); + if (!event.showWithoutTime) { + setRevealAt((prev) => ({ minutes: eventDate.getHours() * 60 + eventDate.getMinutes(), nonce: (prev?.nonce ?? 0) + 1 })); + } }, [jumpTo]); const handleSaveEvent = useCallback(async (data: Partial, requestedScheduling?: boolean, editor?: EventEditorState) => { @@ -990,9 +1067,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = updates: data, editor, sendScheduling: sendSchedulingMessages, + fromEditor: true, }); - setShowEventModal(false); - setEditEvent(null); return; } // false: a plugin stopped it and said why; the form stays open. @@ -1034,6 +1110,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = await save(requestedScheduling); }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]); + // Calendars a new task can go into: writable, not a subscription. + const writableTaskCalendars = useMemo( + () => calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)), + [calendars, isSubscriptionCalendar], + ); + // Calendars a new event can go into: never a subscription or read-only one (#762). const writableCalendars = useMemo( () => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)), @@ -1042,12 +1124,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // A click or drag on empty grid space. On wide screens it opens the quick // editor at that spot; on phones the full editor, as before. - const handleGridCreate = useCallback((start: Date, end?: Date) => { + const handleGridCreate = useCallback((start: Date, end?: Date, allDay = false) => whenEditorsDiscarded(() => { if (isMobile) { - openCreateModal(start, end); + openCreateModal(start, end, allDay); return; } - const finalEnd = end ?? new Date(start.getTime() + 60 * 60 * 1000); + const finalEnd = end ?? (allDay ? start : new Date(start.getTime() + 60 * 60 * 1000)); const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0]; setShowEventModal(false); setEditEvent(null); @@ -1058,25 +1140,58 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = start, end: finalEnd, title: "", + allDay, calendarId: prev && writableCalendars.some((c) => c.id === prev.calendarId) ? prev.calendarId : preferred?.id ?? "", })); - }, [isMobile, openCreateModal, writableCalendars, closeDetail]); + }), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded]); + + // 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. + const handleGridCreateAllDay = useCallback((day: Date) => { + const start = new Date(day); + start.setHours(0, 0, 0, 0); + handleGridCreate(start, start, true); + }, [handleGridCreate]); const closeQuickCreate = useCallback(() => setQuickDraft(null), []); + // The create button and `n` make what the view shows: a task in Tasks. + const inTasksView = normalizedViewMode === "tasks"; + const openCreateForView = useCallback(() => { + if (inTasksView) openCreateTaskModal(); + else openCreateModal(); + }, [inTasksView, openCreateTaskModal, openCreateModal]); + const createLabel = inTasksView ? t("events.new_task") : t("events.create"); + const handleQuickCreateMoreOptions = useCallback(() => { if (!quickDraft) return; const draft = quickDraft; - openCreateModal(draft.start, draft.end); + openCreateModal(draft.start, draft.end, draft.allDay); setDefaultCalendarIdForCreate(draft.calendarId || undefined); setDefaultModalTitle(draft.title || undefined); }, [quickDraft, openCreateModal]); const handleQuickCreateSave = useCallback(async () => { if (!quickDraft) return; - const { start, end, title, calendarId } = quickDraft; + const { start, end, title, calendarId, allDay } = quickDraft; + if (allDay) { + setQuickDraft(null); + await handleSaveEvent({ + uid: generateUUID(), + title: title.trim(), + start: format(start, "yyyy-MM-dd'T'00:00:00"), + duration: "P1D", + timeZone: null, + showWithoutTime: true, + calendarIds: { [calendarId]: true }, + status: "confirmed", + freeBusyStatus: "busy", + privacy: "public", + }); + return; + } const minutes = Math.max(15, Math.round((end.getTime() - start.getTime()) / 60000)); const hours = Math.floor(minutes / 60); const rest = minutes % 60; @@ -1105,7 +1220,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = start: quickDraft.start, end: quickDraft.end, title: quickDraft.title.trim() || t("events.no_title"), - allDay: false, + allDay: !!quickDraft.allDay, calendarId: quickDraft.calendarId, }; }, [quickDraft, pendingPreview, t]); @@ -1137,17 +1252,34 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = type: "delete", event: eventToDelete, sendScheduling: sendSchedulingMessages || undefined, + fromEditor: showEventModal && editEvent?.id === eventToDelete.id, }); - setShowEventModal(false); - setEditEvent(null); return; } + // One event: asked here, from every place Delete is offered (R3). A + // series gets the scope question above instead. + const title = eventToDelete?.title || t("events.no_title"); + const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails); + const ok = await confirmAction({ + title: t("delete_dialog.event_title"), + message: cancels + ? t("delete_dialog.event_message_attendees", { title }) + : t("delete_dialog.event_message", { title }), + confirmText: t("delete_dialog.confirm"), + variant: "destructive", + }); + if (!ok) return; try { - if (await deleteEvent(client, id, sendSchedulingMessages)) toast.success(t("notifications.event_deleted")); - } catch { - toast.error(t("notifications.event_error")); + if (!(await deleteEvent(client, id, sendSchedulingMessages))) return; + toast.success(t("notifications.event_deleted")); + if (editEvent?.id === id) { + setShowEventModal(false); + setEditEvent(null); + } + } catch (error) { + toast.error(describeError(error, t("notifications.event_delete_error"))); } - }, [client, deleteEvent, events, editEvent, t]); + }, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]); const submitRsvp = useCallback(async ( eventId: string, @@ -1171,10 +1303,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series"); return; } - const { type, event, sendScheduling } = pendingScopeAction; + const { type, event, sendScheduling, fromEditor } = pendingScopeAction; const updates = type === "edit" ? pendingScopeAction.updates : undefined; const editor = type === "edit" ? pendingScopeAction.editor : undefined; setPendingScopeAction(null); + // The editor the question came from closes once the change went through. + const closeEditor = () => { + if (!fromEditor) return; + setShowEventModal(false); + setEditEvent(null); + }; // false when a plugin stopped the change (it says why): no "updated". let done = true; @@ -1194,6 +1332,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } if (!done) return; + closeEditor(); toast.success(t("notifications.event_updated")); } else { const deleted = await deleteOccurrence(scope, { @@ -1207,6 +1346,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return; } if (!done) return; + closeEditor(); toast.success(t("notifications.event_deleted")); } try { @@ -1215,10 +1355,57 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = debug.error("Failed to refresh calendar after scope operation"); } } catch (error) { - toast.error(describeError(error, t("notifications.event_error"))); + toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error"))); } }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]); + // A drag or resize. A series asks which occurrences it covers, as the + // editor does; one event moves at once and can be moved back. + const handleMoveEvent = useCallback(async (event, updates, sendScheduling, kind) => { + if (!client) { toast.error(t("notifications.event_move_error")); return; } + if (isRecurringEvent(event)) { + const moved = { ...event, ...updates }; + const allDay = !!event.showWithoutTime; + // The attendees stay as they are; the save works out the rest from + // where the occurrence went, as for a time changed in the editor. + const attendees = getParticipantList(event, { resolveName: resolveContactName }) + .filter((p) => !p.isOrganizer && p.email) + .map((p) => ({ name: p.name, email: p.email.trim() })); + setPendingScopeAction({ + type: "edit", + event, + updates, + sendScheduling, + editor: { + initialAttendees: attendees, + attendees, + when: { allDay, start: getEventStartDate(moved), end: allDay ? getEventDisplayEndDate(moved) : getEventEndDate(moved) }, + ruleChanged: false, + }, + }); + return; + } + try { + // false: a plugin stopped it and said why. + if (!(await updateEvent(client, event.id, updates, sendScheduling))) return; + } catch (error) { + toast.error(describeError(error, t(kind === "resize" ? "notifications.event_resize_error" : "notifications.event_move_error"))); + return; + } + const before: Partial = { start: event.start, duration: event.duration }; + if (updates.timeZone !== undefined) before.timeZone = event.timeZone; + toast.success(t(kind === "resize" ? "notifications.event_updated" : "notifications.event_moved"), { + action: { + label: t("notifications.undo"), + onClick: () => { + void updateEvent(client, event.id, before, sendScheduling).catch((error: unknown) => { + toast.error(describeError(error, t("notifications.event_move_error"))); + }); + }, + }, + }); + }, [client, updateEvent, resolveContactName, describeError, t]); + const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => { if (!client) return; // An answer on one occurrence of a series asks whether it covers just @@ -1327,26 +1514,58 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useEffect(() => { const handleKey = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; - if (target.getAttribute("contenteditable") === "true") return; - if (showEventModal || detailEvent) return; + if (e.defaultPrevented) return; + if (isEditableEventTarget(e)) return; + // An open menu or dialog owns the keyboard; a hover preview doesn't, + // only a card the user opened. + if (isContextMenuOpen()) return; + if (document.querySelector('[aria-modal="true"]')) return; + if (showEventModal || showTaskModal || detailPinned) return; - switch (e.key) { - case "ArrowLeft": e.preventDefault(); navigatePrev(); break; - case "ArrowRight": e.preventDefault(); navigateNext(); break; - case "t": goToToday(); break; - case "m": setViewMode("month"); break; - case "w": setViewMode("week"); break; - case "d": setViewMode("day"); break; - case "a": setViewMode("agenda"); break; - case "k": if (enableCalendarTasks) setViewMode("tasks"); break; - case "n": openCreateModal(); break; + const action = calendarShortcutAction(e); + if (!action) return; + switch (action) { + case "prev": e.preventDefault(); navigatePrev(); break; + case "next": e.preventDefault(); navigateNext(); break; + case "today": goToToday(); break; + case "month": setViewMode("month"); break; + case "week": setViewMode("week"); break; + case "day": setViewMode("day"); break; + case "agenda": setViewMode("agenda"); break; + case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break; + case "new": openCreateForView(); break; } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]); + }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]); + + // The command palette's Calendar group, while this calendar is the one in + // front (in the Pro shell: its focused tab). + const tCommon = useTranslations("common"); + const paletteViews: { mode: CalendarViewMode; key: string }[] = [ + { mode: "day", key: "d" }, + { mode: "week", key: "w" }, + { mode: "month", key: "m" }, + { mode: "agenda", key: "a" }, + { mode: "tasks", key: "k" }, + ]; + useRegisterCommands(tSidebar("calendar"), [ + { id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() }, + { id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks }, + { id: "today", label: t("events.go_to_today"), icon: CalendarDays, shortcut: "t", run: goToToday, when: () => normalizedViewMode !== "tasks" }, + ...paletteViews.map(({ mode, key }) => ({ + id: `view-${mode}`, + label: t(`commands.${mode}`), + icon: CalendarDays, + shortcut: key, + run: () => { setMobileReturnToMonth(false); setViewMode(mode); }, + when: () => mode !== normalizedViewMode && (mode !== "tasks" || enableCalendarTasks), + })), + { id: "import", label: t("toolbar.import"), icon: Upload, run: () => setShowImportModal(true), when: () => !demoMode }, + { id: "subscribe", label: t("toolbar.subscribe"), icon: Globe, run: () => setShowSubscriptionModal(true), when: () => !demoMode }, + { id: "refresh", label: tCommon("refresh"), icon: RefreshCw, shortcut: "F5", run: () => { void refreshCalendar(); } }, + ], isEmbedded ? isFocusedProTab : !proInterfaceActive); const birthdayEvents = useMemo(() => { if (!showBirthdayCalendar || !dateRange) return []; @@ -1399,6 +1618,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]; }, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]); + // What the user may change, by the rights of each event's calendar + // (birthdays, subscriptions and read-only shares can't be changed). + const editabilityContext = useMemo(() => ({ + calendarsById: new Map(allCalendars.map((c) => [c.id, c])), + userCalendarAddresses: currentUserEmails, + isSubscriptionCalendar, + }), [allCalendars, currentUserEmails, isSubscriptionCalendar]); + const canEditEvent = useCallback( + (event: CalendarEvent) => getEventEditability(event, editabilityContext) === "editable", + [editabilityContext], + ); + + // The event the user opened, marked in the grid (APP.md: the selected + // event gets the primary ring). + const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null; + const visibleEvents = useMemo(() => { const filtered = events.filter((e) => { if (!e.start || !e.calendarIds) return false; @@ -1413,8 +1648,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The tasks the task list shows; plugin buttons in its toolbar act on them. const listedTaskIds = useMemo( - () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id), - [taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted], + () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id), + [taskStore.tasks, selectedCalendarIds, taskStore.filter], ); // Tasks on the month, week and day grids follow the calendar filter like @@ -1466,19 +1701,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ) : null; const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? ( -
- @@ -1642,7 +1881,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {viewContent} {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
-
+
)}
@@ -1658,9 +1897,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onNext={navigateNext} onToday={goToToday} onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }} - onCreateEvent={() => openCreateModal()} - onImport={() => setShowImportModal(true)} - onSubscribe={() => setShowSubscriptionModal(true)} + onCreateEvent={openCreateForView} + createLabel={createLabel} + onImport={demoMode ? undefined : () => setShowImportModal(true)} + onSubscribe={demoMode ? undefined : () => setShowSubscriptionModal(true)} + onRefresh={() => { void refreshCalendar(); }} isMobile={isMobile} onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} calendars={displayCalendars} @@ -1669,6 +1910,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = enableCalendarTasks={enableCalendarTasks} onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : isMobile ? undefined : toggleSidebarCollapsed} sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed} + menuTogglesSidebar={!isNarrow} showCreateButton={isNarrow || sidebarCollapsed} /> ); @@ -1729,15 +1971,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0", !isResizing && "transition-[width] duration-300", isNarrow && cn( - "absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]", "transform transition-transform duration-300 ease-in-out", - !narrowSidebarOpen && "-translate-x-full" + !narrowSidebarOpen && "-translate-x-full rtl:translate-x-full" ) )} style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }} > { setNarrowSidebarOpen(false); openCreateModal(); }} + onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateForView(); }} + createLabel={createLabel} tourTarget={!isNarrow} />
@@ -1813,7 +2056,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } } : undefined} onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined} - onSubscribe={() => setShowSubscriptionModal(true)} + onSubscribe={demoMode ? undefined : () => setShowSubscriptionModal(true)} onEditSubscription={(subId) => setEditingSubscription(subId)} client={client} multiAccountMode={multiAccountEnabled && accountClients.length > 1} @@ -1873,6 +2116,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} + onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined} + onDirtyChange={setEventEditorDirty} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} @@ -1892,6 +2137,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = calendars={displayCalendars} onSave={handleSaveTask} onDelete={handleDeleteTask} + onDirtyChange={setTaskEditorDirty} onClose={() => { setShowTaskModal(false); setEditTask(null); }} isMobile={false} /> @@ -1901,9 +2147,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {/* Floating Create Event Button (mobile) */} {isMobile && ( @@ -1934,12 +2180,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = isOpen={eventContextMenu.isOpen} onClose={closeEventContextMenu} menuRef={eventContextMenuRef} - onEdit={() => openEditModal(eventContextMenu.data!)} + // Edit opens the form, as the card's pencil and `e` do. + onEdit={() => openEditModal(eventContextMenu.data!, true)} onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)} onExportICS={() => handleExportICS(eventContextMenu.data!)} onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)} onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)} onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)} + editable={canEditEvent(eventContextMenu.data)} /> )} @@ -1992,7 +2240,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {detailEvent && detailAnchorRect && ( detailEvent.calendarIds[c.id])} + calendar={allCalendars.find(c => detailEvent.calendarIds[c.id])} anchorRect={detailAnchorRect} onEdit={handleEditFromDetail} onDelete={handleDeleteFromDetail} @@ -2007,6 +2255,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = isSubscriptionCalendar={isSubscriptionCalendar} timeFormat={timeFormat} isMobile={isMobile} + pinned={detailPinned} /> )} @@ -2025,6 +2274,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} + onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined} + onDirtyChange={setEventEditorDirty} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} currentUserEmails={currentUserEmails} isSubscriptionCalendar={isSubscriptionCalendar} @@ -2047,6 +2298,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = calendars={displayCalendars} onSave={handleSaveTask} onDelete={handleDeleteTask} + onDirtyChange={setTaskEditorDirty} onClose={() => { setShowTaskModal(false); setEditTask(null); }} isMobile={true} /> @@ -2095,6 +2347,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setPendingScopeAction(null)} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 9a9662c9f..0d85aa1a9 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -5,8 +5,8 @@ import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { format, isSameDay, eachDayOfInterval, differenceInCalendarDays } from "date-fns"; import { cn } from "@/lib/utils"; -import { Check } from "@/components/icons"; import { EventCard } from "./event-card"; +import { CalendarTaskChip } from "./task-chip"; import { QuickEventInput } from "./quick-event-input"; import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls"; import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils"; @@ -14,8 +14,8 @@ import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-disp import { displayNow, isDisplayToday } from "@/lib/timezone"; import { groupTasksByDueDay } from "@/lib/calendar-tasks"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; -import { useDisplayHours } from "@/hooks/use-display-hours"; -import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; +import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; @@ -44,6 +44,17 @@ interface CalendarDayViewProps extends ScrollWindowViewProps { * Moving an event they organize also asks its attendees again. */ currentUserEmails?: string[]; + /** Whether the user may change an event: only those can be dragged or resized. */ + canEditEvent?: (event: CalendarEvent) => boolean; + /** The event whose card or editor is open: drawn with the selection ring. */ + selectedEventId?: string | null; + /** + * Save a drag or resize. The page asks which occurrences a change to a + * series covers; without it the change goes straight to the store. + */ + onMoveEvent?: MoveEventHandler; + /** A time to bring into view, such as a just-saved event's start. */ + revealAt?: RevealTime | null; } const HOUR_HEIGHT = 64; @@ -75,6 +86,10 @@ export function CalendarDayView({ onToggleTaskComplete, onSelectTask, currentUserEmails, + canEditEvent = () => true, + selectedEventId, + onMoveEvent, + revealAt, }: CalendarDayViewProps) { const t = useTranslations("calendar"); const describeError = useCalendarErrorMessage(); @@ -162,7 +177,7 @@ export function CalendarDayView({ const { hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT }); + } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); const gridHeight = visibleHours.length * HOUR_HEIGHT; @@ -277,6 +292,7 @@ export function CalendarDayView({ describeError, currentUserEmails, isMobile, + onMoveEvent, }); const formatHour = (h: number): string => { @@ -363,6 +379,7 @@ export function CalendarDayView({ onSelectEvent(ev, rect)} @@ -380,37 +397,17 @@ export function CalendarDayView({ <>
0 && "mt-2")}>{t("tasks.label")}
- {dayTasks.map((task) => { - const isCompleted = task.progress === "completed"; - const cal = calendars.find(c => task.calendarIds[c.id]); - const color = cal?.color || "#3b82f6"; - return ( -
onSelectTask?.(task)} - className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors" - style={{ borderInlineStart: `3px solid ${color}` }} - > - - - {task.title || t("tasks.no_title")} - -
- ); - })} + {/* The same chip as the month and week grids. */} + {dayTasks.map((task) => ( +
+ task.calendarIds[c.id])} + onToggleComplete={onToggleTaskComplete} + onSelect={onSelectTask} + /> +
+ ))}
)} @@ -435,7 +432,7 @@ export function CalendarDayView({ inside its row, where the header cannot cover it. */} {(h > firstHour || firstHour > 0) && ( firstHour ? "top-0 -translate-y-1/2" : "top-1", isMobile ? "text-[10px]" : "text-xs", )}> @@ -504,6 +501,7 @@ export function CalendarDayView({ gridHeight - baseHeight, )); const isResizing = resizeVisual?.eventId === ev.id; + const editable = canEditEvent(ev); const top = isResizing ? resizeVisual!.topPx : baseTop; const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); @@ -515,10 +513,11 @@ export function CalendarDayView({ key={ev.id} className="absolute z-10 group/event" data-calendar-event - style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }} + style={{ top, height, insetInlineStart: `${leftPct}%`, width: `${widthPct}%`, paddingInline: 2 }} > onSelectEvent(ev, rect)} @@ -526,31 +525,31 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} - draggable + draggable={editable} /> {/* An edge beyond the visible hours has nothing to grab. */} - {!clip.clippedStart && ( + {editable && !clip.clippedStart && (
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > -
+
)} - {!clip.clippedEnd && ( + {editable && !clip.clippedEnd && (
handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > -
+
)}
@@ -600,7 +599,7 @@ export function CalendarDayView({
-
+
{formatSnapTime(dropTarget.minutes, timeFormat)}
diff --git a/components/calendar/calendar-load-error-notice.tsx b/components/calendar/calendar-load-error-notice.tsx index b296f7ecc..b12856326 100644 --- a/components/calendar/calendar-load-error-notice.tsx +++ b/components/calendar/calendar-load-error-notice.tsx @@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo const t = useTranslations("calendar.status"); return (
- + {what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")} {hasMenu && ( - + + )}
); @@ -388,7 +416,7 @@ export function CalendarSidebarPanel({ )} {canShare && ( { closeContextMenu(); onShareCalendar(cal); }} /> @@ -453,20 +481,18 @@ export function CalendarSidebarPanel({ {expanded ? ( ) : ( - + )} - {label} + {label} {onAdd && ( - + + )}
); @@ -488,8 +514,8 @@ export function CalendarSidebarPanel({ {enableCalendarTasks && (
); } diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index c5ca2b993..4cc3d2530 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -3,7 +3,10 @@ import { useState, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw } from "@/components/icons"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; @@ -22,8 +25,12 @@ interface CalendarToolbarProps { onToday: () => void; onViewModeChange: (mode: CalendarViewMode) => void; onCreateEvent: () => void; + /** The create button's name: what it makes in the current view. */ + createLabel?: string; onImport?: () => void; onSubscribe?: () => void; + /** Reload calendars, events and subscriptions. */ + onRefresh?: () => void; isMobile?: boolean; firstDayOfWeek?: number; onNavigateBack?: () => void; @@ -35,6 +42,8 @@ interface CalendarToolbarProps { onMenuClick?: () => void; /** Whether the sidebar is currently shown (for the burger's aria-expanded). */ sidebarOpen?: boolean; + /** The burger shows and hides the sidebar beside the grid, rather than opening it over it. */ + menuTogglesSidebar?: boolean; /** * Show a compact create button in the bar. The full "Create" button lives * at the top of the sidebar, so this is only needed while it is hidden. @@ -60,8 +69,10 @@ export function CalendarToolbar({ onToday, onViewModeChange, onCreateEvent, + createLabel, onImport, onSubscribe, + onRefresh, isMobile, firstDayOfWeek = 1, onNavigateBack, @@ -71,9 +82,11 @@ export function CalendarToolbar({ enableCalendarTasks, onMenuClick, sidebarOpen, + menuTogglesSidebar, showCreateButton, }: CalendarToolbarProps) { const t = useTranslations("calendar"); + const tCommon = useTranslations("common"); const { weekStartsOn, formatMonthYear, @@ -89,8 +102,12 @@ export function CalendarToolbar({ const menuViews: CalendarViewMode[] = enableCalendarTasks ? ["day", "week", "month", "agenda", "tasks"] : ["day", "week", "month", "agenda"]; - const [showViewMenu, setShowViewMenu] = useState(false); - const viewMenuRef = useRef(null); + const { + contextMenu: viewMenu, + openContextMenuFrom: openViewMenuFrom, + closeContextMenu: closeViewMenu, + menuRef: viewMenuRef, + } = useContextMenu(); const [showCalendarDropdown, setShowCalendarDropdown] = useState(false); const dropdownRef = useRef(null); @@ -135,41 +152,49 @@ export function CalendarToolbar({ } }; - const [showImportDropdown, setShowImportDropdown] = useState(false); - const importDropdownRef = useRef(null); + // Tasks have no dates to step through. + const hasDateNav = viewMode !== "tasks"; + // The ⋮ menu: what the calendar as a whole offers. + const { + contextMenu: moreMenu, + openContextMenuFrom: openMoreMenuFrom, + closeContextMenu: closeMoreMenu, + menuRef: moreMenuRef, + } = useContextMenu(); + const hasMoreMenu = !!(onImport || onSubscribe || onRefresh); + const pick = (fn: () => void) => () => { closeMoreMenu(); fn(); }; + const moreButton = hasMoreMenu ? ( + <> + + + {onImport && } + {onSubscribe && } + {onRefresh && (onImport || onSubscribe) && } + {onRefresh && } + + + ) : null; + // The same entries in the phone header's ⋮. + const phoneMenuItems = hasMoreMenu ? (close: () => void) => ( + <> + {onImport && { close(); onImport(); }} />} + {onSubscribe && { close(); onSubscribe(); }} />} + {onRefresh && { close(); onRefresh(); }} />} + + ) : undefined; - useEffect(() => { - if (!showImportDropdown) return; - function handleClickOutside(e: MouseEvent) { - if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) { - setShowImportDropdown(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [showImportDropdown]); - - useEffect(() => { - if (!showViewMenu && !showImportDropdown) return; - function handleClickOutside(e: MouseEvent) { - if (viewMenuRef.current && !viewMenuRef.current.contains(e.target as Node)) { - setShowViewMenu(false); - } - } - function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") { - setShowViewMenu(false); - setShowImportDropdown(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKey); - }; - }, [showViewMenu, showImportDropdown]); @@ -182,36 +207,35 @@ export function CalendarToolbar({ {/* Row 1: Back / Date nav / Today */}
{onMenuClick && ( - + + + )} {onNavigateBack && ( - + + + )} - - + {hasDateNav && ( + + + + )} + {getDateLabel()} - - - + {hasDateNav && ( + <> + + + + + + )} + {/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */} +
{/* Row 2: View switcher pills + calendar toggle */} @@ -222,7 +246,7 @@ export function CalendarToolbar({ key={v} onClick={() => onViewModeChange(v)} className={cn( - "flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation", + "flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation", v === viewMode ? "bg-primary text-primary-foreground" : "text-muted-foreground active:bg-muted" @@ -235,16 +259,16 @@ export function CalendarToolbar({ {calendars && selectedCalendarIds && onToggleVisibility && (
- + + {showCalendarDropdown && (

@@ -259,7 +283,7 @@ export function CalendarToolbar({ key={cal.id} onClick={() => onToggleVisibility(cal.id)} className={cn( - "flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation", + "flex items-center gap-2 w-full px-2 min-h-11 rounded-md text-sm transition-colors duration-150 touch-manipulation", "hover:bg-muted" )} > @@ -270,7 +294,7 @@ export function CalendarToolbar({ )} style={isVisible ? { backgroundColor: color, borderColor: color } : undefined} /> - + {cal.name} @@ -299,7 +323,7 @@ export function CalendarToolbar({ key={cal.id} onClick={() => onToggleVisibility(cal.id)} className={cn( - "flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation", + "flex items-center gap-2 w-full px-2 min-h-11 rounded-md text-sm transition-colors duration-150 touch-manipulation", "hover:bg-muted" )} > @@ -310,7 +334,7 @@ export function CalendarToolbar({ )} style={isVisible ? { backgroundColor: color, borderColor: color } : undefined} /> - + {cal.name} @@ -338,10 +362,11 @@ export function CalendarToolbar({ {onMenuClick && ( )} - - - -

+ {hasDateNav && ( + <> + + + + + )} +

{getDateLabel()}

- {(onImport || onSubscribe) && ( -
- - {showImportDropdown && ( -
- {onImport && ( - - )} - {onSubscribe && ( - - )} -
- )} -
- )} + {moreButton} -
+
- {showViewMenu && ( -
- {menuViews.map((v) => ( - - ))} -
- )} + + {menuViews.map((v) => ( + + ))} +
); diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 050c48f09..a985681ed 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -17,8 +17,8 @@ import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remap import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; -import { useDisplayHours } from "@/hooks/use-display-hours"; -import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; +import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; @@ -39,6 +39,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; + /** A click on a day in the all-day row: a new all-day event there. */ + onCreateAllDay?: (date: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; isMobile?: boolean; @@ -51,6 +53,17 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { * Moving an event they organize also asks its attendees again. */ currentUserEmails?: string[]; + /** Whether the user may change an event: only those can be dragged or resized. */ + canEditEvent?: (event: CalendarEvent) => boolean; + /** The event whose card or editor is open: drawn with the selection ring. */ + selectedEventId?: string | null; + /** + * Save a drag or resize. The page asks which occurrences a change to a + * series covers; without it the change goes straight to the store. + */ + onMoveEvent?: MoveEventHandler; + /** A time to bring into view, such as a just-saved event's start. */ + revealAt?: RevealTime | null; } const HOUR_HEIGHT = 60; @@ -78,6 +91,7 @@ export function CalendarWeekView({ onContextMenuEvent, onContextMenuEmpty, onCreateAtTime, + onCreateAllDay, firstDayOfWeek = 1, timeFormat = "24h", isMobile, @@ -86,6 +100,10 @@ export function CalendarWeekView({ onToggleTaskComplete, onSelectTask, currentUserEmails, + canEditEvent = () => true, + selectedEventId, + onMoveEvent, + revealAt, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const describeError = useCalendarErrorMessage(); @@ -194,7 +212,7 @@ export function CalendarWeekView({ const { hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT }); + } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); const gridHeight = visibleHours.length * HOUR_HEIGHT; @@ -370,6 +388,7 @@ export function CalendarWeekView({ describeError, currentUserEmails, isMobile, + onMoveEvent, }); const formatHour = (h: number): string => { @@ -460,7 +479,7 @@ export function CalendarWeekView({ {isExpandable && ( -
- -
-
- - setName(e.target.value)} - placeholder={t("name_placeholder")} - className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" - disabled={isSubmitting} - onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }} - /> -
- -
- - -
- - -
- -
- + + } + > +
+
+ + setName(e.target.value)} + placeholder={t("name_placeholder")} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + disabled={isSubmitting} + onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }} + />
+ +
+ + {t("color")} + + +
+ +
-
+ ); } diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 1113fd0b7..cd851d5ff 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, type CSSProperties, type DragEvent } from "react"; +import { useCallback, useRef, useState, type CSSProperties, type DragEvent } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -10,6 +10,7 @@ import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participan import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { useSettingsStore } from "@/stores/settings-store"; import { readableTextOn } from "@/lib/color-transform"; +import { useLongPress } from "@/hooks/use-long-press"; interface EventCardProps { event: CalendarEvent; @@ -109,6 +110,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM try { return format(d, fmt); } catch { return "--:--"; } }; const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`; + // An all-day event is read out as such, not as "00:00 – 23:00". + const spokenTime = event.showWithoutTime ? t("events.all_day") : timeString; // iTIP CANCEL marks the attendee's copy with status "cancelled" instead of // deleting it (#572), and a declined invitation stays on the calendar too // (#1110) - render both struck through and dimmed. @@ -116,7 +119,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails); const isInactive = isCancelled || isDeclined; const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null; - const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`; + const ariaLabel = `${event.title || t("events.no_title")}, ${spokenTime}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`; const handleDragStart = useCallback((e: DragEvent) => { e.stopPropagation(); @@ -144,11 +147,29 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM draggable: true as const, onDragStart: handleDragStart, onDragEnd: handleDragEnd, - "aria-roledescription": "draggable event", + "aria-roledescription": t("events.draggable_role"), } : {}; const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined; + // Touch screens have no right-click: a long press opens the same menu, + // at the finger (R7). + const pressedRef = useRef(null); + const longPress = useLongPress(({ clientX, clientY }) => { + const target = pressedRef.current; + if (!onContextMenu || !target) return; + onContextMenu({ + type: "contextmenu", clientX, clientY, detail: 1, currentTarget: target, target, + preventDefault: () => {}, stopPropagation: () => {}, + } as unknown as React.MouseEvent, event); + }, !!onContextMenu); + const touchProps = onContextMenu ? { + onTouchStart: (e: React.TouchEvent) => { pressedRef.current = e.currentTarget; longPress.onTouchStart(e); }, + onTouchMove: longPress.onTouchMove, + onTouchEnd: longPress.onTouchEnd, + onTouchCancel: longPress.onTouchCancel, + } : {}; + if (variant === "chip") { return ( + )} {canEditBody && ( - + )} - - +

- {showDeleteConfirm && ( -
- - {t("detail.delete_confirm")} - - - -
- )} -
{event.status === "tentative" && ( - + {t("detail.tentative")} )} {event.status === "cancelled" && ( - + {t("detail.cancelled")} )} @@ -547,7 +534,7 @@ export function EventDetailPopover({
{participants.slice(0, 5).map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( @@ -555,7 +542,7 @@ export function EventDetailPopover({ )} - +
))} {participants.length > 5 && ( @@ -588,7 +575,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {calendar.name}
)} @@ -623,20 +610,18 @@ export function EventDetailPopover({
- - -
+
)} @@ -710,29 +655,3 @@ export function EventDetailPopover({ return createPortal(popover, document.body); } - -function ParticipantStatusBadge({ - status, - t, -}: { - status: CalendarParticipant["participationStatus"]; - t: ReturnType; -}) { - const colors: Record = { - accepted: "text-success", - declined: "text-destructive", - tentative: "text-warning", - "needs-action": "text-muted-foreground", - }; - const labels: Record = { - accepted: "participants.accepted", - declined: "participants.declined", - tentative: "participants.tentative", - "needs-action": "participants.needs_action", - }; - return ( - - {t(labels[status] || labels["needs-action"])} - - ); -} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 3f45cc010..435696eeb 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -6,7 +6,10 @@ import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle } from "@/components/icons"; +import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; +import { IconButton } from "@/components/ui/icon-button"; +import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor"; @@ -43,6 +46,9 @@ import { } from "@/lib/plugin-hooks"; import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types"; import { eventToView } from "@/lib/plugin-projection"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; export interface PendingEventPreview { start: Date; @@ -73,6 +79,10 @@ interface EventModalProps { onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; onClose: () => void; onPreviewChange?: (preview: PendingEventPreview | null) => void; + /** Opens the event's action menu (export, copy, ...) under the ⋮ of the event view. */ + onMore?: (anchor: HTMLElement) => void; + /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ + onDirtyChange?: (dirty: boolean) => void; currentUserEmails?: string[]; isSubscriptionCalendar?: (calendarId: string) => boolean; /** The server's maxParticipantsPerEvent, for a warning; null when not advertised. */ @@ -248,12 +258,15 @@ export function EventModal({ onRsvp, onClose, onPreviewChange, + onMore, + onDirtyChange, currentUserEmails = [], isSubscriptionCalendar, maxParticipants = null, isMobile = false, }: EventModalProps) { const t = useTranslations("calendar"); + const tCommon = useTranslations("common"); const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; @@ -467,7 +480,6 @@ export function EventModal({ setPreservedAlerts(preserved); setUseDefaultAlerts(false); }, [defaultAlerts]); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); // Set synchronously, so a second save in the same tick is refused too. const savingRef = useRef(false); @@ -501,6 +513,49 @@ export function EventModal({ const [sendInvitations, setSendInvitations] = useState(true); const participantInputRef = useRef(null); + // What the form holds against what it opened with: a form with changes + // asks before X, Cancel or Escape drop them (R5). + const formState = useMemo(() => ({ + title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, + calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations, + }), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, + calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations]); + const [initialForm] = useState(formState); + const isDirty = mode === "edit" && JSON.stringify(formState) !== JSON.stringify(initialForm); + useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]); + useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]); + + const resetForm = useCallback(() => { + const f = initialForm; + setTitle(f.title); setDescription(f.description); setLocation(f.location); setVirtualLocation(f.virtualLocation); + setStartDate(f.startDate); setStartTime(f.startTime); setEndDate(f.endDate); setEndTime(f.endTime); setAllDay(f.allDay); + setCalendarId(f.calendarId); setRecurrence(f.recurrence); setCustomRule(f.customRule); setShowRecurrenceEditor(false); + setUseDefaultAlerts(f.useDefaultAlerts); setAlertRows(f.alertRows); setPreservedAlerts(f.preservedAlerts); setRefreshDefaults(false); + setAttendees(f.attendees); setSendInvitations(f.sendInvitations); + }, [initialForm]); + + const { dialogProps: discardDialogProps, confirm: confirmDiscard } = useConfirmDialog(); + const askingRef = useRef(false); + // Runs `then` at once when nothing would be lost, else after "Discard". + const whenDiscarded = useCallback((then: () => void) => { + if (!isDirty) { then(); return; } + if (askingRef.current) return; + askingRef.current = true; + void confirmDiscard({ + title: t("discard.title"), + message: t("discard.message"), + confirmText: t("discard.confirm"), + cancelText: t("discard.keep"), + variant: "destructive", + }).then((ok) => { + askingRef.current = false; + if (ok) then(); + }); + }, [isDirty, confirmDiscard, t]); + const requestClose = useCallback(() => whenDiscarded(onClose), [whenDiscarded, onClose]); + // Leaving the form of an existing event goes back to its summary. + const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]); + // The event window the attendees' free/busy is checked against. Parsed in // local time like the save path; an all-day event spans its whole days. const availabilityWindow = useMemo(() => { @@ -859,15 +914,38 @@ export function EventModal({ onDuplicate(buildDuplicateEventData(event)); }, [event, onDuplicate]); - const modalRef = useRef(null); + const titleRef = useRef(null); + const primaryRef = useRef(null); + const initialFocusTarget = useCallback( + (): HTMLElement | null => (mode === "edit" ? titleRef.current : primaryRef.current), + [mode], + ); + // Full screen on the phone: a modal that holds the focus. + const modalRef = useFocusTrap({ isActive: !!isMobile, initialFocus: initialFocusTarget }); + + // Beside the grid on wide screens: the focus moves in on open and with + // each switch between summary and form, and goes back on close. + useEffect(() => { + if (isMobile) return; + initialFocusTarget()?.focus(); + }, [isMobile, initialFocusTarget]); + useEffect(() => { + if (isMobile) return; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + return () => { + const active = document.activeElement; + if (opener?.isConnected && (!active || active === document.body)) opener.focus(); + }; + }, [isMobile]); useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + if (askingRef.current) return; if (mode === "edit" && isEdit) { - setMode("view"); + backToView(); } else { - onClose(); + requestClose(); } } if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { @@ -877,150 +955,26 @@ export function EventModal({ }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [onClose, handleSave, canEditBody, mode, isEdit]); - - useEffect(() => { - const modal = modalRef.current; - if (!modal) return; - const focusableEls = modal.querySelectorAll( - 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' - ); - const firstEl = focusableEls[0]; - const lastEl = focusableEls[focusableEls.length - 1]; - - const handler = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - if (e.shiftKey && document.activeElement === firstEl) { - e.preventDefault(); - lastEl?.focus(); - } else if (!e.shiftKey && document.activeElement === lastEl) { - e.preventDefault(); - firstEl?.focus(); - } - }; - modal.addEventListener("keydown", handler); - firstEl?.focus(); - return () => modal.removeEventListener("keydown", handler); - }, []); + }, [requestClose, backToView, handleSave, canEditBody, mode, isEdit]); const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0); - if (rsvpMode && event) { - const startD = getEventStartDate(event); - const endD = getEventEndDate(event); - const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; - const participants = getParticipantList(event, { resolveName: resolveContactName }); - - return ( -
-
-

{event.title || t("events.no_title")}

- -
- -
-
-
- -
-

- {t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })} -

-

- {t("participants.respond_below")} -

-
-
- - {(() => { - const displayEnd = getEventDisplayEndDate(event); - const multiDay = !isSameDay(startD, displayEnd); - if (multiDay && event.showWithoutTime) { - return ( -
-
{formatEventDate(startD)} –
-
{formatEventDate(displayEnd)}
-
- ); - } - if (multiDay) { - return ( -
-
- {formatEventDate(startD)} - {format(startD, timeDisplayFmt)} -
-
- {formatEventDate(endD)} - {format(endD, timeDisplayFmt)} -
-
- ); - } - return ( -
- {formatEventDate(startD)} - {!event.showWithoutTime && ( - - {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} - - )} -
- ); - })()} - - {event.description && ( -

- -

- )} - - {locationName && ( -

{locationName}

- )} - - {participants.length > 0 && ( -
-
- - {t("participants.title")} -
-
- {participants.map(p => ( -
- - {p.name || p.email} - {p.isOrganizer && ( - ({t("participants.organizer").toLowerCase()}) - )} - - -
- ))} -
-
- )} - - {eventDetailProps && } -
-
- -
- -
-
- ); - } - - // View mode: read-only display of event details with Edit button - if (mode === "view" && event) { + // View mode: read-only display of event details with Edit button. An + // invitation the user can only answer is shown by the same view, with a + // banner on top and the answer buttons below - one layout for every event, + // like the card's. + if ((mode === "view" || rsvpMode) && event) { const startD = getEventStartDate(event); const durMin = parseDuration(event.duration); const endD = getEventEndDate(event); - const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; - const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; + const locationName = primaryLocationName(event) ?? null; + // Invitations (Teams above all) leave virtualLocations empty and bury the + // join URL in the description; it counts as the meeting link anyway. + const meeting = findMeetingLink(event); + const virtualLoc = meeting?.uri ?? null; + const locationHref = locationName + ? (() => { const action = locationAction(locationName, meeting); return action.kind === "url" ? action.uri : mapsUrl(action.query); })() + : null; const viewParticipants = getParticipantList(event, { resolveName: resolveContactName }); const recurrenceLabel = getRecurrenceLabel(event, t, locale); const alertLabel = formatEventAlertsLabel(event, calendars, t); @@ -1037,20 +991,39 @@ export function EventModal({
-

{event.title || t("events.no_title")}

+

{event.title || t("events.no_title")}

{eventCalendar && (

{eventCalendar.name}

)}
- + {onMore && ( + onMore(e.currentTarget)}> + + + )} + + +
{/* Content */}
+ {rsvpMode && ( +
+ +
+

+ {t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })} +

+

+ {t("participants.respond_below")} +

+
+
+ )} + {/* Date & Time */}
@@ -1111,13 +1084,12 @@ export function EventModal({ {locationName && ( )} @@ -1142,13 +1114,13 @@ export function EventModal({
{viewParticipants.map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( ({t("participants.organizer").toLowerCase()}) )} - +
))}
@@ -1197,36 +1169,22 @@ export function EventModal({ )} {/* Action Bar */} -
+
{onDelete && canEditBody && ( - showDeleteConfirm ? ( -
- {t("form.delete_confirm")} - - -
- ) : ( - - ) + onDelete(event.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive"> + + )} - {onDuplicate && !showDeleteConfirm && ( - + {onDuplicate && ( + + + )}
- {!showDeleteConfirm && canEditBody && ( - )} @@ -1241,9 +1199,9 @@ export function EventModal({

{isEdit ? t("events.edit") : t("events.create")}

- + + +
@@ -1251,11 +1209,11 @@ export function EventModal({
setTitle(e.target.value)} placeholder={t("form.title")} maxLength={500} - autoFocus />
@@ -1336,7 +1294,7 @@ export function EventModal({ it, Stalwart 1.0 refuses to save more participants - the organizer entry a save adds counts too. */} {maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && ( -

+

{t("participants.limit_warning", { max: maxParticipants })}

@@ -1420,7 +1378,7 @@ export function EventModal({ ? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2' : w.severity === 'info' ? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2' - : 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2' + : 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2' } title={w.message} > @@ -1549,85 +1507,38 @@ export function EventModal({
-
-
- {isEdit && onDelete && ( - showDeleteConfirm ? ( -
-
- - {t("form.delete_confirm")} - - {hasParticipants && ( -

- {t("participants.cancel_notification")} -

- )} -
- - -
- ) : ( - - ) - )} - {isEdit && onDuplicate && !showDeleteConfirm && ( - - )} -
+
+ {isEdit && onDelete && ( + onDelete(event!.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive"> + + + )} + {isEdit && onDuplicate && ( + + + + )} - {!showDeleteConfirm && ( -
+
- )}
+
); } @@ -1643,65 +1554,9 @@ function RsvpBar({ status, onRespond, t }: { t: ReturnType; }) { return ( -
+
{t("participants.rsvp_label")} -
- - - -
+
); } - -function StatusBadge({ status, t }: { - status: CalendarParticipant['participationStatus']; - t: ReturnType; -}) { - // The organizer is marked by the "(组织者)" suffix on the name; the badge - // shows their participation status (organizers default to accepted) rather - // than repeating the organizer label. - const colors: Record = { - accepted: "text-success", - declined: "text-destructive", - tentative: "text-warning", - "needs-action": "text-muted-foreground", - }; - const labels: Record = { - accepted: "participants.accepted", - declined: "participants.declined", - tentative: "participants.tentative", - "needs-action": "participants.needs_action", - }; - return {t(labels[status] || labels["needs-action"])}; -} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 7d7628b08..29967fc5f 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -1,9 +1,12 @@ "use client"; -import { useState, useCallback, useRef, useEffect } from "react"; -import { useTranslations } from "next-intl"; +import { useState, useCallback, useRef, useId } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { buildRecurrenceSummary } from "./recurrence-editor"; import { Button } from "@/components/ui/button"; -import { X, Upload, Check, Loader2, RefreshCw, Globe } from "@/components/icons"; +import { Upload, Check, RefreshCw, Globe } from "@/components/icons"; +import { Dialog } from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; import { format } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -31,7 +34,7 @@ type ImportMode = "file" | "url"; export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { const t = useTranslations("calendar.import"); const tCal = useTranslations("calendar"); - const tCommon = useTranslations("common"); + const locale = useLocale(); const tForm = useTranslations("calendar.form"); const importEvents = useCalendarStore((s) => s.importEvents); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -50,7 +53,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); - const modalRef = useRef(null); + const targetId = useId(); const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) return t("file_too_large"); @@ -215,60 +218,36 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal } }; - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [onClose]); - - useEffect(() => { - const modal = modalRef.current; - if (!modal) return; - const focusableEls = modal.querySelectorAll( - 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' - ); - const firstEl = focusableEls[0]; - const lastEl = focusableEls[focusableEls.length - 1]; - - const handler = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - if (e.shiftKey && document.activeElement === firstEl) { - e.preventDefault(); - lastEl?.focus(); - } else if (!e.shiftKey && document.activeElement === lastEl) { - e.preventDefault(); - firstEl?.focus(); - } - }; - modal.addEventListener("keydown", handler); - firstEl?.focus(); - return () => modal.removeEventListener("keydown", handler); - }, [step]); + // Nothing closes it while the events are being imported. + const close = useCallback(() => { + if (step !== "importing") onClose(); + }, [step, onClose]); return ( -
- @@ -199,7 +199,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) : null; + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null; return (
} - {/* Content */} -
-
+ {/* Content: a button, so Tab reaches the task and Enter opens it. */} +
+ -
+ {dueDateInfo && ( @@ -249,8 +253,8 @@ export function TaskListView({ {cal.name} )} -
-
+ +
); })} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index f719c694f..6d3069d4a 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { IconButton } from "@/components/ui/icon-button"; import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons"; import { format, parseISO } from "date-fns"; @@ -11,6 +12,8 @@ import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/type import { withOwnAlertIds } from "@/lib/calendar-alerts"; 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"; interface TaskModalProps { task?: CalendarTask | null; @@ -19,6 +22,8 @@ interface TaskModalProps { onSave: (data: Partial) => void | boolean | Promise; onDelete?: (id: string) => void; onClose: () => void; + /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ + onDirtyChange?: (dirty: boolean) => void; isMobile?: boolean; } @@ -47,9 +52,12 @@ export function TaskModal({ onSave, onDelete, onClose, + onDirtyChange, isMobile: _isMobile, }: TaskModalProps) { const t = useTranslations("calendar"); + const tCommon = useTranslations("common"); + const id = useId(); const isEdit = !!task; const titleRef = useRef(null); // task-detail-section: the saved task, for an existing task only. @@ -92,6 +100,30 @@ export function TaskModal({ const [alertTouched, setAlertTouched] = useState(false); const [saving, setSaving] = useState(false); + // A form with changes asks before X, Cancel or Escape drop them (R5). + const formState = JSON.stringify({ title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption }); + const [initialForm] = useState(formState); + const isDirty = formState !== initialForm; + useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]); + useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]); + const { dialogProps: discardDialogProps, confirm: confirmDiscard } = useConfirmDialog(); + const askingRef = useRef(false); + const requestClose = useCallback(() => { + if (!isDirty) { onClose(); return; } + if (askingRef.current) return; + askingRef.current = true; + void confirmDiscard({ + title: t("discard.title"), + message: t("discard.message"), + confirmText: t("discard.confirm"), + cancelText: t("discard.keep"), + variant: "destructive", + }).then((ok) => { + askingRef.current = false; + if (ok) onClose(); + }); + }, [isDirty, onClose, confirmDiscard, t]); + useEffect(() => { titleRef.current?.focus(); }, []); @@ -164,13 +196,13 @@ export function TaskModal({ const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - onClose(); + requestClose(); } if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); handleSave(); } - }, [onClose, handleSave]); + }, [requestClose, handleSave]); return (
@@ -179,9 +211,9 @@ export function TaskModal({

{isEdit ? t("tasks.edit") : t("tasks.create")}

- + + +
{/* Body */} @@ -192,6 +224,7 @@ export function TaskModal({ value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("tasks.title_placeholder")} + aria-label={t("tasks.title_placeholder")} className="text-base font-medium" /> @@ -200,18 +233,20 @@ export function TaskModal({ value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t("tasks.description_placeholder")} + aria-label={t("form.description")} rows={3} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" /> {/* Due Date */}
-
- -
+ + + } + > +
+ {addressBooks.map((book) => { + const isCurrent = book.id === currentId; + const isSelected = selected === book.id; + return ( + + ); + })} +
+ + ); +} diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index d8c85ec68..3cc1707d6 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { ContextMenu, ContextMenuItem, @@ -18,11 +18,13 @@ import { Trash2, Copy, Printer, + FolderInput, + CheckSquare, } from "@/components/icons"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactPrimaryEmail } from "@/stores/contact-store"; import { exportContact, useContactExportBusy } from "./contact-export"; -import { printContact } from "./contact-print"; +import { printContact, printLabels } from "./contact-print"; import { toast } from "@/stores/toast-store"; function getContactPrimaryPhone(contact: ContactCard): string { @@ -43,11 +45,22 @@ interface ContactContextMenuProps { menuRef: React.RefObject; isMultiSelect?: boolean; selectedCount?: number; - onOpen: () => void; - onEdit: () => void; + /** Left out where the contact is already open (the detail's ⋮). */ + onOpen?: () => void; + /** Touch: start selecting with this contact (there is no Ctrl+click). */ + onSelect?: () => void; + /** Left out where Edit is a button next to the menu. */ + onEdit?: () => void; onDelete: () => void; - onAddToGroup: () => void; + onAddToGroup?: () => void; + /** Leave out what the detail shows as buttons of its own. */ + inDetail?: boolean; + /** Writes to the contact in the app's composer. */ + onCompose?: () => void; onDuplicate?: () => void; + /** Move to another address book; undefined where there is none to move to. */ + onMoveTo?: () => void; + onBatchMoveTo?: () => void; onBatchExport?: () => void; onBatchAddToGroup?: () => void; onBatchDelete?: () => void; @@ -62,15 +75,21 @@ export function ContactContextMenu({ isMultiSelect = false, selectedCount = 1, onOpen, + onSelect, onEdit, onDelete, onAddToGroup, + onCompose, onDuplicate, + onMoveTo, + onBatchMoveTo, + inDetail = false, onBatchExport, onBatchAddToGroup, onBatchDelete, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const locale = useLocale(); const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); const showBatchActions = isMultiSelect && selectedCount > 1; @@ -81,11 +100,6 @@ export function ContactContextMenu({ onClose(); }; - const handleSendEmail = () => { - if (!email) return; - window.location.href = `mailto:${email}`; - }; - const handleCall = () => { if (!phone) return; window.location.href = `tel:${phone}`; @@ -108,7 +122,7 @@ export function ContactContextMenu({ }; const handlePrint = () => { - void printContact(contact); + void printContact(contact, printLabels(t, locale)); }; if (showBatchActions) { @@ -123,6 +137,9 @@ export function ContactContextMenu({ onClick={handle(() => onBatchAddToGroup?.())} disabled={!onBatchAddToGroup} /> + {onBatchMoveTo && ( + + )} - - - {(email || phone) && } - {email && ( + {/* R6 order: Open, write, Edit, then the rest; the detail's ⋮ leaves + out what it has as buttons (Edit, Send email, Call). */} + {onOpen && } + {onSelect && } + {email && onCompose && !inDetail && ( )} - {phone && ( + {phone && !inDetail && ( )} + {onEdit && } + {!inDetail && } + {onAddToGroup && ( + + )} + {onDuplicate && ( + + )} + {onMoveTo && ( + + )} {email && ( handleCopy(phone))} /> )} - - - {onDuplicate && ( - - )} ; - label: string; - onClick: () => void; - destructive?: boolean; - disabled?: boolean; - separator?: false; - } - | { separator: true }; - interface ContactDetailProps { contact: ContactCard | null; onEdit: () => void; onDelete: () => void; onAddToGroup?: () => void; onDuplicate?: () => void; + /** Move to another address book; undefined where there is none to move to. */ + onMoveTo?: () => void; /** Compose an email to this contact in the app (no OS mailto handoff). */ onCompose?: () => void; isMobile?: boolean; @@ -64,32 +56,8 @@ function formatPhoneFeatures(features: Record | undefined, labe return Object.keys(features).filter(k => features[k]).map(label).join(", "); } -function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } { - if (typeof dateInput === "object" && dateInput !== null) { - if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") { - const d = new Date(dateInput.utc); - if (!isNaN(d.getTime())) { - return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; - } - return {}; - } - const pd = dateInput as PartialDate; - return { year: pd.year, month: pd.month, day: pd.day }; - } - const s = String(dateInput); - if (s.startsWith("--")) { - const parts = s.substring(2).split("-"); - return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined }; - } - const d = new Date(s); - if (!isNaN(d.getTime())) { - return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }; - } - return {}; -} - function getCompletedYears(dateInput: AnniversaryDate): number | null { - const { year, month, day } = getDateParts(dateInput); + const { year, month, day } = contactDateParts(dateInput); if (!year) return null; const now = new Date(); let years = now.getFullYear() - year; @@ -102,50 +70,17 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { return years; } -function formatDate(dateInput: AnniversaryDate): string { - if (typeof dateInput === 'object' && dateInput !== null) { - if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { - try { - const d = new Date(dateInput.utc as string); - if (!isNaN(d.getTime())) { - return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); - } - } catch { /* fallback */ } - return String(dateInput.utc); - } - const pd = dateInput as PartialDate; - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - const parts: string[] = []; - if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]); - if (pd.day) parts.push(String(pd.day)); - if (pd.year) parts.push(String(pd.year)); - return parts.join(' ') || String(dateInput); - } - const dateStr = String(dateInput); - if (dateStr.startsWith("--")) { - const parts = dateStr.substring(2).split("-"); - const month = parseInt(parts[0], 10); - const day = parts[1] ? parseInt(parts[1], 10) : undefined; - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1]; - } - try { - const d = new Date(dateStr); - if (!isNaN(d.getTime())) { - return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); - } - } catch { /* fallback */ } - return dateStr; -} -export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); + const locale = useLocale(); + // The ⋮ shows the contact's menu, the one right-click shows in the list (R6). + const moreMenu = useContextMenu(); const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key); const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; - const exporting = useContactExportBusy(); const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]); // Tell plugins once per contact shown here, not on every edit of that card. @@ -172,25 +107,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const photo = getContactPhotoRef(contact) ?? null; const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined; - const handleExport = async () => { - if (await exportContact(contact)) toast.success(t("export.success", { count: 1 })); - }; - - const handlePrint = () => { - void printContact(contact, name); - }; - - const moreItems: MoreItem[] = []; - if (onAddToGroup) { - moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup }); - } - if (onDuplicate) { - moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate }); - } - moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting }); - moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint }); - moreItems.push({ separator: true }); - moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true }); const emails = contact.emails ? Object.values(contact.emails) : []; const phones = contact.phones ? Object.values(contact.phones) : []; const orgs = contact.organizations ? Object.values(contact.organizations) : []; @@ -221,7 +137,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
-
+

{name || "-"}

@@ -258,7 +174,32 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli {t("form.edit_title")} - + moreMenu.openContextMenuFrom(e.currentTarget, contact, "end")} + className="touch-manipulation" + > + + + {moreMenu.contextMenu.data && ( + + )}
@@ -391,7 +332,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli return (
- {formatDate(ann.date)} + {formatContactDate(ann.date, locale)} {years !== null && ( · {t(suffixKey, { count: years })} )} @@ -538,8 +479,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli {(contact.created || contact.updated) && (
- {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} - {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} + {contact.created &&
{t("detail.created")}: {formatContactDate(contact.created, locale)}
} + {contact.updated &&
{t("detail.updated")}: {formatContactDate(contact.updated, locale)}
}
)}
@@ -581,71 +522,6 @@ function RowActions({ children }: { children: React.ReactNode }) { ); } -function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onDocMouseDown = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", onDocMouseDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDocMouseDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); - - if (items.length === 0) return null; - - return ( -
- - {open && ( -
- {items.map((item, i) => { - if (item.separator) { - return ; - } - return ( - { - item.onClick(); - setOpen(false); - }} - /> - ); - })} -
- )} -
- ); -} - function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) { return ( + + +
{error && ( -
+
{error}
)} @@ -702,6 +716,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress name={previewName || undefined} email={previewEmail || undefined} contactPhoto={previewPhoto} + // Nothing typed yet: a neutral circle, not a coloured "?". + fallbackColor={previewName || previewEmail ? undefined : "var(--color-muted-foreground)"} size="lg" className="!w-20 !h-20 !text-xl" /> @@ -723,7 +739,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress

{t("photo_hint")}

{photoError && ( -

{photoError}

+

{photoError}

)} {previewPhoto && (
+ {/* Either name is enough (or an organization), so neither is starred. */} +

{t("name_required")}

@@ -855,7 +870,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress }} onBlur={() => handleEmailBlur(i, entry.address)} placeholder={t("email_placeholder")} - className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")} + className={cn("flex-1", emailErrors[i] && "border-destructive focus-visible:border-destructive")} /> {emails.length > 1 && ( - + setEmails(emails.filter((_, j) => j !== i))} className="shrink-0"> + + )}
{emailErrors[i] && ( -

{emailErrors[i]}

+

{emailErrors[i]}

)}
))} @@ -933,9 +948,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress - + setPhones(phones.filter((_, j) => j !== i))} className="shrink-0"> + +
))} + setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1"> + + { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
{ const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} /> @@ -1021,9 +1036,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress placeholder={t("service_placeholder")} className="w-24" /> - + setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0"> + +
))} + setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="shrink-0"> + +
))} + setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0"> + +
))} - + + + + { menu.closeContextMenu(); onEdit(); }} /> + + { menu.closeContextMenu(); onDelete(); }} /> +
{onComposeGroup && hasEmailMembers && ( @@ -117,17 +130,16 @@ export function ContactGroupDetail({ )}
- + +
); })} diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index 8a8b605e7..faf41feff 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Search, Check, X } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -16,6 +16,8 @@ interface ContactGroupFormProps { currentMemberIds?: string[]; onSave: (name: string, memberIds: string[]) => Promise; onCancel: () => void; + /** Reports whether the form holds changes not yet saved. */ + onDirtyChange?: (dirty: boolean) => void; } export function ContactGroupForm({ @@ -24,6 +26,7 @@ export function ContactGroupForm({ currentMemberIds = [], onSave, onCancel, + onDirtyChange, }: ContactGroupFormProps) { const t = useTranslations("contacts"); const isEditing = !!group; @@ -38,6 +41,10 @@ export function ContactGroupForm({ const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") })); + const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members; + useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const filteredIndividuals = useMemo(() => { if (!memberSearch) return individuals; const lower = memberSearch.toLowerCase(); @@ -87,7 +94,7 @@ export function ContactGroupForm({
{error && ( -
+
{error}
)} @@ -109,7 +116,7 @@ export function ContactGroupForm({ {t("groups.members_label")} ({selectedIds.size})
- + toggleMember(id)} - className="hover:text-red-600 dark:hover:text-red-400" + aria-label={t("groups.remove_member", { name: getContactDisplayName(contact) })} + title={t("groups.remove_member", { name: getContactDisplayName(contact) })} + className="hover:text-destructive" > diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 5beb7f290..91c2dbc64 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -2,8 +2,9 @@ import { useState, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; -import { Upload, FileText, AlertTriangle, X, Check } from "@/components/icons"; +import { Upload, FileText, AlertTriangle, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { parseVCard, detectDuplicates } from "@/lib/vcard"; import type { ContactCard } from "@/lib/jmap/types"; @@ -99,16 +100,36 @@ export function ContactImportDialog({ } }; - return ( -
-
-

{t("import.title")}

- -
+ const errorBanner = error && ( +
+ + {error} +
+ ); -
+ return ( + 0 && result === null} + footer={parsed.length > 0 && result === null ? ( + <> +

+ {t("import.selected", { count: selected.size })} +

+ + + + ) : undefined} + > +
{result !== null ? (
@@ -144,21 +165,11 @@ export function ContactImportDialog({

{t("import.file_types")}

- {error && ( -
- - {error} -
- )} + {errorBanner} ) : ( <> - {error && ( -
- - {error} -
- )} + {errorBanner}

@@ -216,22 +227,6 @@ export function ContactImportDialog({ )}

- - {parsed.length > 0 && result === null && ( -
-

- {t("import.selected", { count: selected.size })} -

-
- - -
-
- )} -
+
); } diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index 3817fec66..28e90ad8e 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -1,6 +1,8 @@ "use client"; -import { useCallback, type DragEvent } from "react"; +import { useCallback, useRef, type DragEvent } from "react"; +import { useLongPress } from "@/hooks/use-long-press"; +import { useTranslations } from "next-intl"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; @@ -18,9 +20,19 @@ interface ContactListItemProps { onClick: (e: React.MouseEvent) => void; onCheckboxClick: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void; + /** The keyboard cursor: the row that takes Tab and shows a focus ring. */ + isFocused?: boolean; + onFocus?: () => void; + /** Long-press on touch: the row's menu, as right-click does (R7). */ + onLongPress?: (row: HTMLElement) => void; } -export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) { +export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress }: ContactListItemProps) { + const rowRef = useRef(null); + const longPress = useLongPress(() => { + if (rowRef.current) onLongPress?.(rowRef.current); + }, !!onLongPress); + const t = useTranslations("contacts"); const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const org = contact.organizations @@ -46,20 +58,27 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500; z-index: 9999; white-space: nowrap; pointer-events: none; `; - preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`; + preview.textContent = ids.length === 1 && name ? name : t("drag_count", { count: ids.length }); document.body.appendChild(preview); e.dataTransfer.setDragImage(preview, 0, 0); requestAnimationFrame(() => preview.remove()); - }, [contact.id, name, email, selectedContactIds]); + }, [contact.id, name, email, selectedContactIds, t]); return (
onContextMenu(e, contact) : undefined} className={cn( - "w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border", + "w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", isSelected ? "bg-selection shadow-sm" : "bg-background hover:bg-muted hover:shadow-sm", @@ -69,6 +88,10 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, > {hasSelection && ( + + + )} - + {hasSelection ? : } +
- + onSearchChange("")} - className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" + className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label={t("clear_search")} > )}
- - + +
@@ -367,22 +462,15 @@ export function ContactList({
- + setFiltersOpen(false)}> + +
@@ -489,8 +577,14 @@ export function ContactList({ {/* Bulk action bar */} {hasSelection && ( -
+ // One line at any width: below 28rem the buttons show only their icons. +
- + {t("bulk.selected", { count: selectedContactIds.size })}
- - + )} + - + + +
)} {/* Contact list */} -
+
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
{Array.from({ length: 8 }, (_, i) => ( @@ -551,21 +660,14 @@ export function ContactList({ ))}
) : sorted.length === 0 ? ( -
- {searchQuery || activeFilters > 0 ? ( - <> - {activeFilters > 0 && !searchQuery ? ( - - ) : ( - - )} -

- {searchQuery ? t("empty_search") : t("empty_filtered")} -

-

- {searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")} -

-
+ searchQuery || activeFilters > 0 ? ( + 0 && !searchQuery ? Filter : Search} + title={searchQuery ? t("empty_search") : t("empty_filtered")} + description={searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")} + actions={ + <> {searchQuery && ( )} -
- - ) : ( - <> - -

{t("empty_state_title")}

-

{t("empty_state_subtitle")}

- - - )} -
+ + } + /> + ) : ( + // The whole account is empty, or only the address book, category + // or group on screen: each says so, and both offer New and Import. + + + {onImport && ( + + )} + + } + /> + ) ) : ( (() => { const renderItem = (contact: ContactCard) => ( @@ -600,6 +715,8 @@ export function ContactList({ isSelected={contact.id === selectedContactId} isChecked={selectedContactIds.has(contact.id)} hasSelection={hasSelection} + isFocused={contact.id === cursorId} + onFocus={() => setFocusedId(contact.id)} density={density} selectedContactIds={selectedContactIds} onClick={(e) => { @@ -618,14 +735,15 @@ export function ContactList({ e.stopPropagation(); onToggleSelection(contact.id); }} - onContextMenu={(e, c) => openContextMenu(e, c)} + onContextMenu={(e, c) => { setMenuByTouch(false); openContextMenu(e, c); }} + onLongPress={(row) => { setMenuByTouch(true); openContextMenuFrom(row, contact); }} /> ); return groupByLetter ? (
{groupedSections.map(({ letter, items }) => ( -
-
+
+
{letter}
{items.map(renderItem)} @@ -649,9 +767,14 @@ export function ContactList({ isMultiSelect={selectedContactIds.has(contextMenu.data.id)} selectedCount={selectedContactIds.size} onOpen={() => onSelectContact(contextMenu.data!.id)} + onSelect={menuByTouch ? () => onToggleSelection(contextMenu.data!.id) : undefined} onEdit={() => onEditContact(contextMenu.data!.id)} onDelete={() => onDeleteContact(contextMenu.data!)} onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)} + onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined} + onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined} + onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined} + onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined} onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} diff --git a/components/contacts/contact-print.ts b/components/contacts/contact-print.ts index e00b929c1..24fa9e706 100644 --- a/components/contacts/contact-print.ts +++ b/components/contacts/contact-print.ts @@ -1,4 +1,5 @@ -import type { ContactCard, PartialDate } from "@/lib/jmap/types"; +import type { ContactCard } from "@/lib/jmap/types"; +import { formatContactDate } from "@/lib/contact-dates"; import { getContactDisplayName, getContactPhotoRef } from "@/stores/contact-store"; import { loadContactMediaBlob } from "@/lib/contact-media"; import { blobToDataUri } from "@/lib/data-uri"; @@ -37,7 +38,53 @@ async function printablePhotoUri(contact: ContactCard): Promise undefined) : undefined; } -export async function printContact(contact: ContactCard, displayName?: string): Promise { +/** The print sheet's words, in the UI language. */ +export interface PrintLabels { + locale: string; + contact: string; + emails: string; + email: string; + phones: string; + phone: string; + organizations: string; + organization: string; + titles: string; + title: string; + role: string; + addresses: string; + address: string; + online: string; + onlineService: string; + anniversaries: string; + anniversary: (kind: string) => string; + notes: string; +} + +/** The print labels from the contacts translations (`t` of the "contacts" namespace). */ +export function printLabels(t: (key: string) => string, locale: string): PrintLabels { + return { + locale, + contact: t("detail.section_contact"), + emails: t("detail.emails"), + email: t("detail.email_default_label"), + phones: t("detail.phones"), + phone: t("detail.phone_default_label"), + organizations: t("detail.organizations"), + organization: t("detail.organization_label"), + titles: t("detail.titles"), + title: t("detail.title_label"), + role: t("detail.role_label"), + addresses: t("detail.addresses"), + address: t("detail.address_default_label"), + online: t("detail.online_services"), + onlineService: t("detail.online_service_default_label"), + anniversaries: t("detail.anniversaries"), + anniversary: (kind) => t(`detail.anniversary_${["birth", "death", "wedding"].includes(kind) ? kind : "other"}`), + notes: t("detail.notes"), + }; +} + +export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise { // Open the window before anything is awaited: browsers allow the popup only // while the click that asked for it is being handled. const printWindow = window.open("", "_blank"); @@ -63,62 +110,45 @@ export async function printContact(contact: ContactCard, displayName?: string): const row = (label: string, value: string) => `
${escapeHtml(label)}
${escapeHtml(value)}
`; - section("Email", emails.map(e => row(e.label || "Email", e.address || ""))); - section("Phone", phones.map(p => row(p.label || "Phone", p.number || ""))); + section(labels.emails, emails.map(e => row(e.label || labels.email, e.address || ""))); + section(labels.phones, phones.map(p => row(p.label || labels.phone, p.number || ""))); section( - "Organization", + labels.organizations, orgs.map(o => { const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : ""; const value = [o.name, units].filter(Boolean).join(" \u2014 "); - return row("Organization", value); + return row(labels.organization, value); }), ); - section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || ""))); + section(labels.titles, titles.map(t => row(t.kind === "role" ? labels.role : labels.title, t.name || ""))); section( - "Address", + labels.addresses, addresses.map(a => { const parts = [a.street, a.locality, a.region, a.postcode, a.country] .map(p => (typeof p === "string" ? p : "")) .filter(Boolean); - return row(a.label || "Address", parts.join(", ")); + return row(a.label || labels.address, parts.join(", ")); }), ); section( - "Online", - onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")), + labels.online, + onlineServices.map(s => row(s.label || s.service || labels.onlineService, s.uri || s.user || "")), ); section( - "Anniversary", - anniversaries.map(a => { - const date = a.date; - let formatted = ""; - if (typeof date === "object" && date !== null) { - if ("utc" in date && typeof date.utc === "string") formatted = date.utc; - else { - const parts: string[] = []; - const pd = date as PartialDate; - if (pd.year) parts.push(String(pd.year)); - if (pd.month) parts.push(String(pd.month).padStart(2, "0")); - if (pd.day) parts.push(String(pd.day).padStart(2, "0")); - formatted = parts.join("-"); - } - } else if (date) { - formatted = String(date); - } - return row(a.kind || "Date", formatted); - }), + labels.anniversaries, + anniversaries.map(a => row(labels.anniversary(a.kind || "other"), a.date ? formatContactDate(a.date, labels.locale) : "")), ); - section("Notes", notes.map(n => row("", n.note || ""))); + section(labels.notes, notes.map(n => row("", n.note || ""))); const photoTag = photoUri ? `` : `
${escapeHtml((name || "?").charAt(0).toUpperCase())}
`; const html = ` - + -${escapeHtml(name || "Contact")} +${escapeHtml(name || labels.contact)}