fix: keep occurrence alert edits on 1.0 and count the organizer

Fixes from a second review of the Stalwart 1.0 calendar work.

- Changing the alerts of one occurrence of a series that uses the
  calendar's default alerts said "Event updated" and kept the default
  on 1.0: while a series uses them, 1.0 shows the defaults in place of
  an occurrence's own alerts and refuses `useDefaultAlerts` on a single
  occurrence. The series now stops using the defaults first, keeping
  the alerts it shows under ids of their own, and then the occurrence's
  alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
  1.0: 19 attendees and the organizer are accepted, 20 are refused), so
  the editor warned one attendee late. It warns at the limit now, and
  the warning and the refusal say the organizer counts, with plural
  forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
  proposal, said "could not complete" when the server refused the
  event: the import swallowed its SetError. The banner now asks the
  import to reject with the refusal and names the participant limit
  like the calendar does.
- A range load that finished after a later one could put back its own
  events, range or failure. Only the latest load writes the store, and
  a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
  to its date bounds goes out as it is instead of being expanded, which
  1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
  is used as Intl spells it. 1.0 refused it as a query argument, and
  0.16 and 1.0 both dropped it from new events, which were stored
  floating.
- A failed save no longer shows the store's English toast next to the
  translated message.
- Tests for a limited read that asks later chunks of a request again
  after a refused chunk or a full page.
This commit is contained in:
Linus Rath
2026-09-25 21:21:28 +02:00
parent fabecb2128
commit a8cccf1ad1
38 changed files with 597 additions and 88 deletions
@@ -6,7 +6,8 @@ import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// Stalwart 1.0 refuses to save an event with more participants than the
// calendars capability's maxParticipantsPerEvent (20 by default); 0.16
// advertises the same value without enforcing it. The editor warns, and
// still saves.
// still saves. The organizer entry a save adds counts: live on 1.0, 19
// attendees and the organizer are accepted, 20 and the organizer refused.
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
@@ -50,13 +51,13 @@ function editMeeting(attendees: number, maxParticipants: number | null) {
}
describe('EventModal participant limit', () => {
it('warns when the event has more attendees than the server allows on Stalwart 1.0', () => {
editMeeting(3, 2);
it('warns when the attendees and the organizer are more than the server allows on Stalwart 1.0', () => {
editMeeting(20, 20);
expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning');
});
it('stays quiet within the limit', () => {
editMeeting(2, 2);
it('stays quiet within the limit, the organizer counted', () => {
editMeeting(19, 20);
expect(screen.queryByText('participants.limit_warning')).toBeNull();
});
+2 -2
View File
@@ -480,8 +480,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
// Loads the range the view shows now - not the store's dateRange, which a
// failed load leaves at the range loaded before it.
// Loads the range the view shows now - after a failed load the same as the
// store's dateRange, which then holds the range asked for.
const retryEventsLoad = useCallback(() => {
if (!client || !dateRange) return;
if (multiAccountEnabled && accountClients.length > 0) {
+3 -2
View File
@@ -1211,8 +1211,9 @@ export function EventModal({
onRemove={handleRemoveAttendee}
/>
{/* Only a warning: 0.16 advertises the limit without enforcing
it, Stalwart 1.0 refuses to save more attendees. */}
{maxParticipants != null && attendees.length > maxParticipants && (
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 && (
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
{t("participants.limit_warning", { max: maxParticipants })}
@@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
import type { Email } from '@/lib/jmap/types';
import { jmapSetError } from '@/lib/jmap/jmap-error';
const mocks = vi.hoisted(() => {
const pushMock = vi.fn();
@@ -113,6 +114,7 @@ vi.mock('next-intl', () => ({
maybe: 'Maybe',
decline: 'Decline',
select_calendar: 'Select calendar',
participant_limit: 'The server allows at most {max} participants per event.',
};
let message = strings[key] ?? key;
@@ -748,4 +750,86 @@ describe('CalendarInvitationBanner', () => {
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
});
});
// Stalwart 1.0 refuses an event with more participants than
// maxParticipantsPerEvent; the banner says so instead of "could not
// complete".
describe('when Stalwart 1.0 refuses the participant count', () => {
const LIMIT = jmapSetError('An event cannot have more than 20 participants.', {
type: 'invalidProperties', properties: ['participants'],
});
const invitation = {
uid: 'uid-9',
title: 'All hands',
start: '2026-03-28T10:00:00Z',
participants: {
attendee: {
'@type': 'Participant', email: 'user@example.com', name: 'User',
roles: { attendee: true }, participationStatus: 'needs-action',
},
},
};
it('says so when adding the invitation to the calendar', async () => {
mocks.calendarState.importEvents.mockRejectedValue(LIMIT);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-9' })} />);
await waitFor(() => expect(screen.getByRole('heading', { name: 'All hands' })).toBeInTheDocument());
fireEvent.click(screen.getByText('Add to calendar'));
await waitFor(() => {
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
});
expect(mocks.calendarState.importEvents).toHaveBeenCalledWith(
expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true },
);
});
it('says so when answering an invitation that is not in the calendar yet', async () => {
mocks.calendarState.importEvents.mockRejectedValue(LIMIT);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-10' })} />);
await waitFor(() => expect(screen.getByText('Accept')).toBeInTheDocument());
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
});
// Asked to reject: it used to resolve to 0 and say nothing more.
expect(mocks.calendarState.importEvents).toHaveBeenCalledWith(
expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true },
);
});
it('says so when applying a proposal', async () => {
mocks.calendarState.updateEvent = vi.fn().mockRejectedValue(LIMIT);
mocks.calendarState.events = [{
id: 'event-11', uid: 'uid-11', title: 'Sync', start: '2026-03-27T09:00:00Z', duration: 'PT1H',
participants: {
organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' },
attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
},
}];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{
uid: 'uid-11', title: 'Sync', start: '2026-03-27T10:00:00Z', duration: 'PT1H',
participants: {
organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' },
attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
},
}]);
render(<CalendarInvitationBanner email={makeEmail({
id: 'email-11',
attachments: [{ partId: '1', blobId: 'blob-11', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }],
})} />);
await waitFor(() => expect(screen.getByText('Apply proposed changes')).toBeInTheDocument());
fireEvent.click(screen.getByText('Apply proposed changes'));
await waitFor(() => {
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
});
});
});
});
@@ -627,7 +627,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else if (eventForRsvp) {
setActionError(t('action_failed'));
} else if (calId) {
const imported = await importEvents(client, [parsedEvent], calId);
// A refusal (Stalwart 1.0's participant limit, say) lands in the
// catch below and is said as it is.
const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid
@@ -683,15 +685,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionError(null);
setIsProcessing(true);
try {
const count = await importEvents(client, [parsedEvent], calId);
const count = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
if (count > 0) {
setActionNotice(t('added'));
setState('parsed');
} else {
setActionError(t('action_failed'));
}
} catch {
setActionError(t('action_failed'));
} catch (err) {
setActionError(describeError(err, t('action_failed')));
} finally {
setIsProcessing(false);
}
@@ -746,8 +748,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(t('proposal_applied'));
setState('parsed');
setRsvpStatus(null);
} catch {
setActionError(t('action_failed'));
} catch (err) {
setActionError(describeError(err, t('action_failed')));
} finally {
setIsProcessing(false);
}