Merge branch 'fx/cal' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 04:16:46 +01:00
36 changed files with 2886 additions and 253 deletions
+12
View File
@@ -6,6 +6,7 @@
"not_starred": "Not starred"
},
"calendar": {
"account_changed": "The account changed while the calendar was loading or saving.",
"alerts": {
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
},
@@ -105,6 +106,16 @@
"occurrence_count": "{count, plural, one {# occurrence} other {# occurrences}}",
"until_date": "Until {date}"
},
"search": {
"clear": "Clear search",
"close": "Close search",
"no_results": "No events match “{query}”",
"no_results_hint": "Search looks at titles, places, descriptions and guests.",
"past": "Past",
"repeats": "Repeats",
"title": "Search events",
"upcoming": "Upcoming"
},
"settings": {
"device_sync_desc": "Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.",
"device_sync_title": "Sync to this device",
@@ -123,6 +134,7 @@
"shared_with": "Shared with"
},
"subscription": {
"account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.",
"add_feed": "Add a feed",
"feed_too_large": "Feed is too large",
"fetch_failed": "Could not fetch feed (HTTP {status})",
+43
View File
@@ -104,6 +104,33 @@ describe('calendar operations', () => {
},
);
it('keeps a refusal answered after a new login out of that login\'s memory', async () => {
const client = jmapClient as unknown as { currentSession?: unknown };
client.currentSession = {
accounts: {
'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
'acc-team': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
},
};
try {
resetCalendarAccessDenied();
mockRequest.mockImplementation(async (calls: [string, { accountId: string }, string][]) => {
if (calls.some(([, args]) => args.accountId === 'acc-team')) {
// The previous login was refused; the next one signed in meanwhile.
resetCalendarAccessDenied();
return { methodResponses: [['error', { type: 'forbidden' }, '0']] };
}
return { methodResponses: calls.map(([name, , id]) => [name, name === 'Calendar/get' ? { list: [] } : {}, id]) };
});
await getCalendars();
expect(isCalendarAccessDenied('acc-team')).toBe(false);
} finally {
client.currentSession = undefined;
mockRequest.mockReset();
resetCalendarAccessDenied();
}
});
it('should fetch calendars', async () => {
const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }];
mockRequest.mockResolvedValue({
@@ -368,6 +395,22 @@ describe('calendar operations', () => {
});
describe('getEvents', () => {
// #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends
// the event at 23:59:59 of its start day.
it('ends a timed event without a duration at its start, and leaves tasks alone', async () => {
mockRequest.mockResolvedValue(getAnswer([
{ id: 'z', '@type': 'Event', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', utcEnd: '2026-03-02T22:59:59Z' },
{ id: 't', '@type': 'Task', start: '2026-03-02T09:00:00', utcStart: '2026-03-02T08:00:00Z', due: '2026-03-03T09:00:00' },
{ id: 'a', '@type': 'Event', start: '2026-03-02T00:00:00', showWithoutTime: true },
]));
const [zero, task, allDay] = await getEvents(['z', 't', 'a']);
expect(zero).toMatchObject({ duration: 'PT0S', utcEnd: '2026-03-02T08:00:00Z' });
expect(task).not.toHaveProperty('duration');
expect(allDay).not.toHaveProperty('duration');
});
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
// Stalwart (calcard) names the property `recurrenceRule` (singular,
// single object) — requesting the RFC 8984 plural form returns no
@@ -34,13 +34,15 @@ import {
queryEvents,
isCalendarAccessDenied,
} from '../calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { searchCalendarEvents, useCalendarStore } from '../../stores/calendar-store';
import { useSettingsStore } from '../../stores/settings-store';
import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit';
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants';
import { expandRecurringEvents } from '../../lib/recurrence-expansion';
import { getEventStartDate } from '../../lib/calendar-utils';
import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts';
import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -650,9 +652,11 @@ live('live Stalwart calendar', () => {
expect(useCalendarStore.getState().error).toBeNull();
const toSharer = spy.mock.calls.filter(([calls]) =>
(calls as unknown as [string, Record<string, unknown>][]).some(([, args]) => args.accountId === sharer));
// At most the one probe: no retry on every load.
expect(toSharer.length).toBeLessThanOrEqual(1);
if (toSharer.length === 1) expect(isCalendarAccessDenied(sharer)).toBe(true);
// Both servers list the account for calendars: the one probe, then
// no retry on every load.
expect(client.currentSession?.accounts?.[sharer]?.accountCapabilities?.[CAPABILITIES.CALENDARS], 'the session lists the sharer for calendars').toBeTruthy();
expect(toSharer).toHaveLength(1);
expect(isCalendarAccessDenied(sharer)).toBe(true);
} finally {
spy.mockRestore();
}
@@ -696,6 +700,199 @@ live('live Stalwart calendar', () => {
}, 60_000);
});
describe('a calendar\'s default reminders (#1209)', () => {
let defaultsCalendarId = '';
let otherCalendarId = '';
afterAll(async () => {
const ids = [defaultsCalendarId, otherCalendarId].filter(Boolean);
if (ids.length > 0) {
await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: ids, onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
}
}, 30_000);
it('are set and cleared in the calendar and given to a new event that uses them', async () => {
await connectSingleton();
const [[, made]] = await raw(client, [['Calendar/set', {
accountId: client.accountId, create: { c: { name: `${RUN} defaults`, color: '#3b82f6' } },
}, '0']]);
defaultsCalendarId = made.created?.c?.id ?? '';
expect(defaultsCalendarId, JSON.stringify(made.notCreated)).toBeTruthy();
await useCalendarStore.getState().fetchCalendars();
// As the calendar editor saves them.
// Another calendar of the account with defaults under the plain ids
// the editor wrote before: 1.0 wants them unique in the account.
const plain = await raw(client, [['Calendar/set', {
accountId: client.accountId,
create: { o: { name: `${RUN} other defaults`, defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 5 }]) } },
}, '0']]);
otherCalendarId = plain[0][1].created?.o?.id ?? '';
const target = { id: defaultsCalendarId };
await useCalendarStore.getState().updateCalendar(defaultsCalendarId, {
defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix(target, false)),
defaultAlertsWithoutTime: remindersToDefaultAlerts([{ minutesBefore: 60 * 24 }], {}, defaultAlertIdPrefix(target, true)),
});
await useCalendarStore.getState().fetchCalendars();
const cal = useCalendarStore.getState().calendars.find((c) => c.id === defaultsCalendarId)!;
expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]);
expect(alertsToReminders(calendarDefaultAlerts(cal, true))).toEqual([{ minutesBefore: 60 * 24 }]);
// As the event editor creates an event on the defaults.
const created = await useCalendarStore.getState().createEvent({
'@type': 'Event', title: `${RUN} on defaults`, start: '2027-12-03T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', useDefaultAlerts: true,
} as Partial<CalendarEvent>, defaultsCalendarId);
const [stored] = await getEvents([created.id]);
expect(stored.useDefaultAlerts).toBe(true);
const effective = getEffectiveAlerts(stored, useCalendarStore.getState().calendars) ?? {};
expect(alertsToReminders(effective)).toEqual([{ minutesBefore: 15 }]);
// Emptied.
await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { defaultAlertsWithTime: remindersToDefaultAlerts([]) });
const [[, after]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [defaultsCalendarId], properties: ['defaultAlertsWithTime'] }, '0']]);
expect(Object.keys(after.list[0].defaultAlertsWithTime ?? {})).toEqual([]);
}, 60_000);
});
describe('event search', () => {
it('finds an event outside the loaded window by a word of its title, and a series by its stored event', async () => {
await connectSingleton();
const word = RUN.replace(/[^a-z0-9]/gi, '');
await createEvent({ '@type': 'Event', title: `${RUN} ${word}single`, start: '2027-12-10T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as Partial<CalendarEvent>, calendarId);
await createEvent(seriesData(`${word}series`, '2027-01-04T10:00:00', 4), calendarId);
await useCalendarStore.getState().fetchCalendars();
const single = await searchCalendarEvents([`${word}single`]);
expect(single.map((e) => e.title)).toEqual([`${RUN} ${word}single`]);
const series = await searchCalendarEvents([`${word}series`]);
expect(series).toHaveLength(1);
expect(series[0].recurrenceRules?.[0]?.count).toBe(4);
// Every word has to match.
expect(await searchCalendarEvents([`${word}single`, 'nosuchwordanywhere'])).toEqual([]);
}, 60_000);
});
describe('a zero-length event (#837)', () => {
it('ends at its start, stored without a duration or imported from an .ics', async () => {
await connectSingleton();
// As another client stores it: no duration at all.
const [[, made]] = await raw(client, [['CalendarEvent/set', {
accountId: client.accountId,
create: { z: { '@type': 'Event', title: `${RUN} zero`, start: '2027-12-01T09:00:00', timeZone: 'Europe/Berlin', calendarIds: { [calendarId]: true } } },
}, '0']]);
const id = made.created?.z?.id;
expect(id, JSON.stringify(made.notCreated)).toBeTruthy();
const [read] = await getEvents([id]);
expect(read.utcStart).toBe('2027-12-01T08:00:00Z');
expect(read.utcEnd).toBe(read.utcStart);
// An .ics whose DTEND equals its DTSTART, imported.
const uid = `${RUN}-zero-import`;
await useCalendarStore.getState().fetchCalendars();
const { imported } = await useCalendarStore.getState().importEvents([
{ uid, title: `${RUN} zero imported`, start: '2027-12-02T09:00:00', timeZone: 'Europe/Berlin' },
], calendarId);
expect(imported).toBe(1);
const [stored] = await seriesByUid(client, uid);
const [[, got]] = await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [stored.id], properties: ['duration', 'utcStart', 'utcEnd'] }, '0']]);
expect(got.list[0].utcEnd).toBe(got.list[0].utcStart);
}, 60_000);
});
describe('a subscribed feed', () => {
const FEED_URL = 'https://feeds.invalid/live.ics';
let feed = '';
const realFetch = globalThis.fetch;
const vevent = (uid: string, title: string, start: string, extra = '') =>
`BEGIN:VEVENT\r\nUID:${RUN}-${uid}\r\nDTSTAMP:20271101T000000Z\r\nDTSTART:${start}\r\nDURATION:PT1H\r\nSUMMARY:${RUN} ${title}\r\n${extra}END:VEVENT\r\n`;
const calendar = (...events: string[]) =>
`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//live//EN\r\n${events.join('')}END:VCALENDAR\r\n`;
async function mirrorEvents(calendarId: string): Promise<Record<string, any>[]> {
const res = await raw(client, [
['CalendarEvent/query', { accountId: client.accountId, filter: { inCalendar: calendarId } }, 'q'],
['CalendarEvent/get', {
accountId: client.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'uid', 'title', 'start', 'locations', 'recurrenceRule'],
}, 'g'],
]);
return (res.find((r) => r[2] === 'g')?.[1].list ?? []) as Record<string, any>[];
}
beforeAll(() => {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) !== FEED_URL) return realFetch(input, init);
return new Response(feed, { status: 200, headers: { 'Content-Type': 'text/calendar' } });
}) as typeof fetch;
});
afterAll(async () => {
globalThis.fetch = realFetch;
const { useCalendarSubscriptionsStore } = await import('../../stores/calendar-subscriptions-store');
for (const sub of useCalendarSubscriptionsStore.getState().subscriptions) {
if (sub.calendarId && sub.name.includes(RUN)) {
await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [sub.calendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
}
}
useCalendarSubscriptionsStore.setState({ subscriptions: [] });
}, 60_000);
it('gives the events it still carries what it changed, drops what it left out and writes nothing when unchanged', async () => {
await connectSingleton();
const { useCalendarSubscriptionsStore } = await import('../../stores/calendar-subscriptions-store');
const subs = useCalendarSubscriptionsStore.getState();
feed = calendar(
vevent('a', 'talk', '20271206T090000Z', 'LOCATION:Room 1\r\n'),
vevent('b', 'workshop', '20271207T090000Z'),
vevent('w', 'weekly', '20271208T090000Z', 'RRULE:FREQ=WEEKLY;COUNT=3\r\n'),
);
const sub = await subs.addSubscription({ name: `${RUN} feed`, url: FEED_URL });
expect(sub.calendarId).toBeTruthy();
const first = await mirrorEvents(sub.calendarId!);
expect(first.map((e) => e.title).sort()).toEqual([`${RUN} talk`, `${RUN} weekly`, `${RUN} workshop`]);
const talkId = first.find((e) => e.uid === `${RUN}-a`)!.id;
// Unchanged: no write at all.
const jmap = await singleton();
const spy = vi.spyOn(jmap, 'request');
try {
await useCalendarSubscriptionsStore.getState().syncSubscription(sub.id);
const writes = spy.mock.calls.filter(([calls]) =>
(calls as unknown as [string, Record<string, unknown>][]).some(([name, args]) =>
name === 'CalendarEvent/set' && (args.update || args.destroy || args.create)));
expect(writes).toEqual([]);
} finally {
spy.mockRestore();
}
expect(useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === sub.id)?.lastError).toBeNull();
// Moved, renamed and in another room; one gone, one new, the series longer.
feed = calendar(
vevent('a', 'talk moved', '20271206T110000Z', 'LOCATION:Room 2\r\n'),
vevent('c', 'new talk', '20271209T090000Z'),
vevent('w', 'weekly', '20271208T090000Z', 'RRULE:FREQ=WEEKLY;COUNT=5\r\n'),
);
await useCalendarSubscriptionsStore.getState().syncSubscription(sub.id);
expect(useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === sub.id)?.lastError).toBeNull();
const second = await mirrorEvents(sub.calendarId!);
expect(second.map((e) => e.title).sort()).toEqual([`${RUN} new talk`, `${RUN} talk moved`, `${RUN} weekly`]);
const talk = second.find((e) => e.uid === `${RUN}-a`)!;
// The same event, changed in place.
expect(talk.id).toBe(talkId);
expect((await getEvents([talk.id]))[0].utcStart).toBe('2027-12-06T11:00:00Z');
expect(Object.values(talk.locations ?? {}).map((l: any) => l.name)).toEqual(['Room 2']);
const weekly = second.find((e) => e.uid === `${RUN}-w`)!;
const rule = Array.isArray(weekly.recurrenceRule) ? weekly.recurrenceRule[0] : weekly.recurrenceRule;
expect(rule?.count).toBe(5);
await useCalendarSubscriptionsStore.getState().removeSubscription(sub.id);
const [[, gone]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [sub.calendarId] }, '0']]);
expect(gone.notFound).toEqual([sub.calendarId]);
}, 90_000);
});
describe('tasks', () => {
it('lists the account\'s own tasks and none of another account\'s', async () => {
const [[, created]] = await raw(client, [['CalendarEvent/set', {
@@ -708,17 +905,58 @@ live('live Stalwart calendar', () => {
await useCalendarStore.getState().fetchTasks();
expect(useCalendarStore.getState().tasks.map((t) => t.title)).toContain(`${RUN} task`);
if (!USER2) return;
if (!USER2 || !other) return;
const theirCalendar = (await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]))[0][1]
.list.find((c: { isDefault?: boolean }) => c.isDefault)?.id;
const [[, theirs]] = await raw(other, [['CalendarEvent/set', {
accountId: other.accountId,
create: { t: { '@type': 'Task', title: `${RUN} their task`, calendarIds: { [theirCalendar]: true }, due: '2027-11-02T10:00:00', timeZone: 'Europe/Berlin' } },
}, '0']]);
expect(theirs.created?.t?.id, JSON.stringify(theirs.notCreated)).toBeTruthy();
// The first account's task load is still waiting for its tasks when
// the client switches to the second account and loads that one's.
const second = new JMAPClient();
await second.connect(SERVER!, USER2, PASS);
const jmap = await singleton();
jmap.restoreSnapshot(second.snapshot());
const request = jmap.request.bind(jmap);
let switched = false;
let waiting = false;
let release!: () => void;
const answers = new Promise<void>((resolve) => { release = resolve; });
const spy = vi.spyOn(jmap, 'request').mockImplementation(async (calls, using) => {
const readsTasks = (calls as unknown as [string, Record<string, unknown>][]).some(([name, args]) =>
name === 'CalendarEvent/get' && Array.isArray(args.properties) && args.properties.includes('alerts'));
if (switched || !readsTasks) return request(calls, using);
// Sent by the first account before the switch, answered after it.
waiting = true;
await answers;
return client.request(calls, using);
});
try {
useCalendarStore.getState().reset();
const outgoing = useCalendarStore.getState().fetchTasks();
await waitFor(async () => waiting, (w) => w, 10_000);
expect(waiting, 'the first account\'s tasks are being read').toBe(true);
switched = true;
jmap.restoreSnapshot(second.snapshot());
useCalendarStore.getState().reset();
await useCalendarStore.getState().fetchCalendars();
await useCalendarStore.getState().fetchTasks();
expect(useCalendarStore.getState().tasks.map((t) => t.title)).not.toContain(`${RUN} task`);
const loaded = useCalendarStore.getState().tasks;
expect(loaded.map((t) => t.title)).toContain(`${RUN} their task`);
release();
await outgoing;
// Untouched by the first account's load, which reads its task ids in
// the second account by then: the e2e accounts repeat each other's
// ids, so what that finds can even look right.
expect(useCalendarStore.getState().tasks).toBe(loaded);
expect(loaded.map((t) => t.title)).not.toContain(`${RUN} task`);
} finally {
release();
spy.mockRestore();
await connectSingleton();
}
}, 60_000);
+53 -7
View File
@@ -1,9 +1,9 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types';
import type { Alert, Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import {
RECURRENCE_BASE_PROPERTIES,
SYNTHETIC_ID_PROBE,
@@ -151,6 +151,9 @@ export function toLocalDateTime(value: string | Date, timeZone?: string): string
// skipped for the rest of the session (the session lists every account with
// the calendars capability, including ones that only share mail with us).
const calendarAccessDenied = new Set<string>();
// One up with every reset (a new login): a refusal answered after one was
// given to the login before, whose shared accounts the next one may read.
let calendarAccessGeneration = 0;
export function isCalendarAccessDenied(accountId: string): boolean {
return calendarAccessDenied.has(accountId);
@@ -158,6 +161,7 @@ export function isCalendarAccessDenied(accountId: string): boolean {
export function resetCalendarAccessDenied(): void {
calendarAccessDenied.clear();
calendarAccessGeneration += 1;
}
// Stalwart 1.0 answers `accountNotSupportedByMethod` for an account that
@@ -298,6 +302,18 @@ export function normalizeTaskProgress<T extends Partial<CalendarEvent>>(event: T
: { ...event, progress: normalized };
}
/**
* A timed event without a duration lasts zero seconds (RFC 8984's default
* PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an event
* at 23:59:59 of its start day, so its utcEnd is wrong (#837). Tasks carry
* no duration and are left alone. Mirrors the webmail's withZeroDurationEnd.
*/
export function withZeroDurationEnd<T extends Partial<CalendarEvent>>(event: T): T {
if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) return event;
if (isTaskLikeObject(event as ScannedCalendarObject)) return event;
return { ...event, duration: 'PT0S', utcEnd: event.utcStart };
}
/**
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
* properties Stalwart expects on write, dropping null rule fields. An empty
@@ -432,6 +448,7 @@ const CALENDAR_PROPERTIES = [
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const accessGeneration = calendarAccessGeneration;
const probeKey = syntheticIdProbeKey();
const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey);
const methodCalls: [string, Record<string, unknown>, string][] = [
@@ -475,7 +492,7 @@ export async function getCalendars(): Promise<Calendar[]> {
isShared: true,
}));
} catch (err) {
noteCalendarAccessError(sharedAccountId, err);
if (accessGeneration === calendarAccessGeneration) noteCalendarAccessError(sharedAccountId, err);
return [];
}
}),
@@ -965,7 +982,8 @@ export async function getEvents(
options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
{ withOverrides: !options?.expanded, timeZone },
));
return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)));
return (list as unknown as CalendarEvent[]).map((e) =>
withZeroDurationEnd(normalizeTaskProgress(normalizeRecurrenceProperties(e))));
}
export type EventIdentityFields = Pick<CalendarEvent, 'id' | 'uid' | 'calendarIds'>;
@@ -992,6 +1010,30 @@ export async function findEventsByUid(uid: string, accountId?: string): Promise<
return getEvents(ids, accountId);
}
/**
* The ids of the events a CalendarEvent/query `filter` finds (the event
* search's `text` conditions), latest start first, at most `limit`. Not
* limited to a date window: a series is found by its stored event.
*/
export async function searchEventIds(
filter: Record<string, unknown>,
accountId?: string,
limit = 100,
): Promise<string[]> {
const account = accountId || jmapClient.accountId;
return withCalendarTimeZone(async (timeZone) => {
const args: Record<string, unknown> = {
accountId: account,
filter,
sort: [{ property: 'start', isAscending: false }],
limit,
};
if (timeZone) args.timeZone = timeZone;
const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING);
return methodResult<{ ids: string[] }>(res).ids ?? [];
});
}
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
// / reply / cancellation emails to participants. We pass it whenever an event
// has participants so creating/updating/deleting a meeting notifies attendees.
@@ -1203,8 +1245,9 @@ export async function createCalendar(
name: string,
color?: string,
description?: string,
targetAccountId?: string,
): Promise<Calendar> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim();
const res = await jmapClient.request(
@@ -1247,8 +1290,8 @@ export async function setDefaultCalendar(
* The name of the user's calendar `id`, or null when the account has no
* calendar by that id.
*/
export async function getCalendarName(id: string): Promise<string | null> {
const accountId = jmapClient.accountId;
export async function getCalendarName(id: string, targetAccountId?: string): Promise<string | null> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']],
USING,
@@ -1276,6 +1319,9 @@ export interface CalendarUpdates {
description?: string | null;
sortOrder?: number;
isSubscribed?: boolean;
// `{}` clears them: Stalwart refuses null for these maps.
defaultAlertsWithTime?: Record<string, Alert>;
defaultAlertsWithoutTime?: Record<string, Alert>;
}
// Rename / recolour / describe a calendar (Calendar/set update).
+164 -5
View File
@@ -10,11 +10,22 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from '../icons';
import type { Calendar } from '../../api/types';
import { Check, Plus, X } from '../icons';
import type { Alert, Calendar } from '../../api/types';
import {
REMINDER_PRESETS,
alertsToReminders,
calendarDefaultAlerts,
defaultAlertIdPrefix,
formatReminder,
preservedAlerts,
remindersToDefaultAlerts,
sameReminders,
type Reminder,
} from '../../lib/calendar-alerts';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
@@ -24,6 +35,79 @@ export interface CalendarEditValues {
name: string;
color: string;
description: string;
/** Set only when the user changed them. */
defaultAlertsWithTime?: Record<string, Alert>;
defaultAlertsWithoutTime?: Record<string, Alert>;
}
/** One list of a calendar's default reminders: the times, a remove button each, presets to add. */
function DefaultRemindersEditor({
label,
reminders,
keptCount,
onChange,
styles,
c,
t,
}: {
label: string;
reminders: Reminder[];
keptCount: number;
onChange: (next: Reminder[]) => void;
styles: ReturnType<typeof makeStyles>;
c: ThemePalette;
t: TranslateFn;
}) {
const [picking, setPicking] = React.useState(false);
const available = REMINDER_PRESETS.filter((p) => !reminders.some((r) => r.minutesBefore === p));
return (
<View style={styles.reminders}>
<Text style={styles.label}>{label}</Text>
{reminders.length === 0 && keptCount === 0 && (
<Text style={styles.muted}>{t('calendar.alerts.none', 'No reminders')}</Text>
)}
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.reminderText}>{formatReminder(r.minutesBefore, t)}</Text>
<Pressable
onPress={() => onChange(reminders.filter((x) => x.minutesBefore !== r.minutesBefore))}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
{keptCount > 0 && (
<Text style={styles.muted}>
{t('calendar.alerts.kept', "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}", { count: keptCount })}
</Text>
)}
{available.length > 0 && (
<Pressable style={styles.addRow} onPress={() => setPicking((v) => !v)} accessibilityRole="button">
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>{t('calendar.alerts.add', 'Add reminder')}</Text>
</Pressable>
)}
{picking && (
<View style={styles.presets}>
{available.map((preset) => (
<Pressable
key={preset}
style={styles.preset}
onPress={() => {
onChange([...reminders, { minutesBefore: preset }]);
setPicking(false);
}}
>
<Text style={styles.presetText}>{formatReminder(preset, t)}</Text>
</Pressable>
))}
</View>
)}
</View>
);
}
interface CalendarEditSheetProps {
@@ -47,15 +131,26 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
const [description, setDescription] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// The calendar's default reminders (#1209): the owner's to set, for an
// existing calendar. What the lists can't show is written back unchanged.
const editsDefaults = !!calendar && !calendar.isShared;
const initialTimed = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, false)), [calendar]);
const initialAllDay = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, true)), [calendar]);
const keptTimed = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, false)), [calendar]);
const keptAllDay = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, true)), [calendar]);
const [timedReminders, setTimedReminders] = React.useState<Reminder[]>([]);
const [allDayReminders, setAllDayReminders] = React.useState<Reminder[]>([]);
React.useEffect(() => {
if (!visible) return;
setName(calendar?.name ?? '');
setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]);
setDescription(calendar?.description ?? '');
setTimedReminders(initialTimed);
setAllDayReminders(initialAllDay);
setError(null);
setSaving(false);
}, [visible, calendar]);
}, [visible, calendar, initialTimed, initialAllDay]);
const handleSave = async () => {
const trimmed = name.trim();
@@ -63,7 +158,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color, description: description.trim() });
const values: CalendarEditValues = { name: trimmed, color, description: description.trim() };
// Ids unique in the account: Stalwart 1.0 refuses one another calendar uses.
if (calendar && editsDefaults && !sameReminders(timedReminders, initialTimed)) {
values.defaultAlertsWithTime = remindersToDefaultAlerts(timedReminders, keptTimed, defaultAlertIdPrefix(calendar, false));
}
if (calendar && editsDefaults && !sameReminders(allDayReminders, initialAllDay)) {
values.defaultAlertsWithoutTime = remindersToDefaultAlerts(allDayReminders, keptAllDay, defaultAlertIdPrefix(calendar, true));
}
await onSave(values);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong'));
@@ -120,6 +223,35 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
style={[styles.input, styles.multiline]}
multiline
/>
{editsDefaults && (
<>
<Text style={styles.sectionTitle}>{t('calendar.management.default_alerts', 'Default reminders')}</Text>
<Text style={styles.muted}>
{t(
'calendar.management.default_alerts_hint',
"New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.",
)}
</Text>
<DefaultRemindersEditor
label={t('calendar.management.default_alerts_timed', 'Events with a time')}
reminders={timedReminders}
keptCount={Object.keys(keptTimed).length}
onChange={setTimedReminders}
styles={styles}
c={c}
t={t}
/>
<DefaultRemindersEditor
label={t('calendar.management.default_alerts_all_day', 'All-day events')}
reminders={allDayReminders}
keptCount={Object.keys(keptAllDay).length}
onChange={setAllDayReminders}
styles={styles}
c={c}
t={t}
/>
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
<View style={styles.footer}>
@@ -185,5 +317,32 @@ function makeStyles(c: ThemePalette) {
swatchActive: { borderWidth: 2, borderColor: c.text },
error: { ...typography.caption, color: c.error },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
muted: { ...typography.caption, color: c.textMuted },
reminders: { gap: spacing.xs },
reminderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 36,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
reminderText: { ...typography.body, color: c.text },
addRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
addRowText: { ...typography.bodyMedium, color: c.primary },
presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
preset: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
presetText: { ...typography.caption, color: c.text },
});
}
@@ -0,0 +1,313 @@
import React from 'react';
import {
ActivityIndicator,
FlatList,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { format } from 'date-fns';
import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search';
import {
getEventColor,
getEventEndDate,
getEventStartDate,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const DEBOUNCE_MS = 250;
interface CalendarSearchSheetProps {
visible: boolean;
calendars: Calendar[];
timeFormat?: TimeFormat;
currentUserEmails?: string[];
/** Called once the search is closed. */
onSelectEvent: (event: CalendarEvent) => void;
onClose: () => void;
}
type Row =
| { kind: 'header'; key: string; label: string }
| { kind: 'event'; key: string; event: CalendarEvent };
/**
* Search the events by title, place, description and participants (webmail
* calendar-search). The loaded events answer at once; the server's text
* search adds the rest of the calendar, whatever the date.
*/
export function CalendarSearchSheet({
visible,
calendars,
timeFormat,
currentUserEmails = [],
onSelectEvent,
onClose,
}: CalendarSearchSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const loaded = useCalendarStore((s) => s.events);
const [query, setQuery] = React.useState('');
const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null);
React.useEffect(() => {
if (!visible) {
setQuery('');
setRemote(null);
}
}, [visible]);
const terms = React.useMemo(() => searchTerms(query), [query]);
const key = terms.join(' ');
React.useEffect(() => {
if (!visible || terms.length === 0) return;
let cancelled = false;
const timer = setTimeout(() => {
searchCalendarEvents(terms)
.then((events) => { if (!cancelled) setRemote({ query: key, events }); })
// A server that can't search leaves the loaded matches.
.catch(() => { if (!cancelled) setRemote({ query: key, events: [] }); });
}, DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, key]);
// iOS can't present the detail sheet while this one is still going away:
// the event opens once it is gone (onDismiss, iOS only).
const pending = React.useRef<CalendarEvent | null>(null);
const choose = (event: CalendarEvent) => {
if (Platform.OS === 'ios') {
pending.current = event;
onClose();
return;
}
onClose();
onSelectEvent(event);
};
const answered = remote?.query === key ? remote.events : null;
const searching = terms.length > 0 && !answered;
const results = React.useMemo(() => {
if (terms.length === 0) return null;
const local = loaded.filter((e) => eventMatches(e, terms));
return orderSearchResults(local, answered ?? [], displayNow().getTime());
}, [loaded, terms, answered]);
const rows = React.useMemo<Row[]>(() => {
if (!results) return [];
const out: Row[] = [];
if (results.upcoming.length > 0) {
out.push({ kind: 'header', key: 'h-upcoming', label: t('calendar.search.upcoming', 'Upcoming') });
for (const event of results.upcoming) out.push({ kind: 'event', key: `u-${event.id}`, event });
}
if (results.past.length > 0) {
out.push({ kind: 'header', key: 'h-past', label: t('calendar.search.past', 'Past') });
for (const event of results.past) out.push({ kind: 'event', key: `p-${event.id}`, event });
}
return out;
}, [results, t]);
const time = timePattern(timeFormat);
const thisYear = displayNow().getFullYear();
const renderEvent = (event: CalendarEvent) => {
const start = getEventStartDate(event);
const end = getEventEndDate(event);
const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event));
const color = getEventColor(event, calendars);
const inactive = isInactiveEvent(event, currentUserEmails);
const place = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const meta = [place, calendar?.name].filter(Boolean).join(' · ');
// A series the server found by its stored event.
const repeats = !event.recurrenceId && !!event.recurrenceRules?.length;
const title = event.title || t('calendar.events.no_title', '(No title)');
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => choose(event)}
accessibilityRole="button"
accessibilityLabel={title}
>
<View style={styles.when}>
<Text style={styles.whenDay}>
{format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })}
</Text>
<Text style={styles.whenTime}>
{event.showWithoutTime
? t('calendar.events.all_day', 'All day')
: `${format(start, time, { locale })} – ${format(end, time, { locale })}`}
</Text>
</View>
<View style={[styles.dot, inactive ? { borderWidth: 1.5, borderColor: color } : { backgroundColor: color }]} />
<View style={styles.what}>
<Text style={[styles.title, inactive && styles.inactive]} numberOfLines={1}>{title}</Text>
{(meta || repeats) ? (
<View style={styles.metaRow}>
{place ? <MapPin size={12} color={c.textMuted} /> : null}
{meta ? <Text style={styles.meta} numberOfLines={1}>{meta}</Text> : null}
{repeats && (
<>
<Repeat size={12} color={c.textMuted} />
<Text style={styles.meta}>{t('calendar.search.repeats', 'Repeats')}</Text>
</>
)}
</View>
) : null}
</View>
</Pressable>
);
};
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
onDismiss={() => {
const event = pending.current;
pending.current = null;
if (event) onSelectEvent(event);
}}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.close', 'Close search')}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
<View style={styles.field}>
<Search size={16} color={c.textMuted} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t('calendar.search.title', 'Search events')}
placeholderTextColor={c.mutedForeground}
style={styles.input}
autoFocus
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t('calendar.search.title', 'Search events')}
/>
{query ? (
<Pressable
onPress={() => setQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.clear', 'Clear search')}
>
<X size={16} color={c.textMuted} />
</Pressable>
) : null}
</View>
</View>
{searching && rows.length === 0 ? (
<View style={styles.center}><ActivityIndicator color={c.primary} /></View>
) : results && rows.length === 0 ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>
{t('calendar.search.no_results', 'No events match “{query}”', { query: query.trim() })}
</Text>
<Text style={styles.emptyHint}>
{t('calendar.search.no_results_hint', 'Search looks at titles, places, descriptions and guests.')}
</Text>
</View>
) : (
<FlatList
data={rows}
keyExtractor={(row) => row.key}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
ListHeaderComponent={searching ? <ActivityIndicator style={styles.more} color={c.primary} /> : null}
renderItem={({ item }) => (item.kind === 'header'
? <Text style={styles.section}>{item.label}</Text>
: renderEvent(item.event))}
/>
)}
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
field: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 40,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, gap: spacing.sm },
emptyTitle: { ...typography.bodyMedium, color: c.text, textAlign: 'center' },
emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
more: { paddingVertical: spacing.sm },
section: {
...typography.captionMedium,
color: c.textSecondary,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
rowPressed: { backgroundColor: c.surface },
when: { width: 104 },
whenDay: { ...typography.body, color: c.text },
whenTime: { ...typography.caption, color: c.textMuted },
dot: { width: 9, height: 9, borderRadius: 5, marginTop: 6 },
what: { flex: 1, minWidth: 0 },
title: { ...typography.bodyMedium, color: c.text },
inactive: { textDecorationLine: 'line-through', color: c.textMuted },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: 2 },
meta: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
});
}
+98 -8
View File
@@ -8,8 +8,9 @@ import {
StyleSheet,
Platform,
Alert as NativeAlert,
type LayoutChangeEvent,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
import {
X,
@@ -49,8 +50,10 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import {
alertsToReminders,
calendarDefaultAlerts,
reminderFieldsToSave,
preservedAlerts,
withOwnAlertIds,
formatReminder,
REMINDER_PRESETS,
REMINDER_UNIT_MINUTES,
@@ -72,6 +75,8 @@ import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -185,6 +190,25 @@ export function EventModal({
const isEdit = !!event;
const resolveContactName = useContactNameResolver();
// The keyboard covered the bottom fields (location, description; #81).
// iOS: the scroll view insets itself and scrolls the focused field into
// view (automaticallyAdjustKeyboardInsets). Android: the edge-to-edge
// dialog does not resize for the keyboard, so the scroll view ends above
// it, and Android's ScrollView brings the focused field back into view
// when it shrinks.
const insets = useSafeAreaInsets();
const keyboard = useKeyboardObstruction(insets.bottom);
const fullHeight = React.useRef(0);
const [containerHeight, setContainerHeight] = React.useState(0);
const onContainerLayout = (e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
if (keyboard === 0) fullHeight.current = height;
setContainerHeight(height);
};
const keyboardInset = Platform.OS === 'android'
? editorKeyboardInset(keyboard, insets.bottom, fullHeight.current - containerHeight)
: 0;
const [title, setTitle] = React.useState('');
const [description, setDescription] = React.useState('');
const [allDay, setAllDay] = React.useState(false);
@@ -206,6 +230,10 @@ export function EventModal({
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]);
// A new event starts on its calendar's default reminders (#1209) until
// the user customizes them.
const [useDefaults, setUseDefaults] = React.useState(false);
const onDefaults = !event && useDefaults;
// Alerts the picker can't show (absolute / end-relative / non-display)
// ride along untouched so a save never drops them.
const [preserved, setPreserved] = React.useState<Record<string, Alert>>({});
@@ -246,6 +274,7 @@ export function EventModal({
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts));
setUseDefaults(false);
setPreserved(preservedAlerts(event.alerts));
setSendInvitations(true);
setCustomReminderOpen(false);
@@ -264,6 +293,7 @@ export function EventModal({
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setUseDefaults(true);
setPreserved({});
setSendInvitations(true);
setCustomReminderOpen(false);
@@ -347,7 +377,11 @@ export function EventModal({
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
// Untouched reminders are not sent: an event on its calendar's
// default alerts keeps them.
...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds),
// A new event on its calendar's defaults: the server copies them
// into it (sending them as alerts too would double them).
...(onDefaults
? { useDefaultAlerts: true }
: reminderFieldsToSave(event, reminders, preserved, defaultAlertIds)),
locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
};
@@ -433,6 +467,18 @@ export function EventModal({
const availablePresets = REMINDER_PRESETS.filter(
(p) => !reminders.some((r) => r.minutesBefore === p),
);
// The selected calendar's defaults, for an event with a time or all day.
const calendarDefaults = calendarDefaultAlerts(selectedCalendar, allDay);
const defaultReminders = alertsToReminders(calendarDefaults);
// Start the event's own reminders from the defaults, to change them.
const customizeReminders = () => {
setReminders(defaultReminders);
// Under ids of their own: Stalwart 1.0 drops an event's alert under a
// default alert's id.
const kept = preservedAlerts(calendarDefaults);
setPreserved(withOwnAlertIds(Object.values(kept), Object.keys(calendarDefaults)));
setUseDefaults(false);
};
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
@@ -441,7 +487,7 @@ export function EventModal({
presentationStyle="fullScreen"
onRequestClose={onClose}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
<View style={styles.header}>
<Pressable
onPress={onClose}
@@ -467,9 +513,10 @@ export function EventModal({
</View>
<ScrollView
style={styles.body}
style={[styles.body, keyboardInset > 0 && { marginBottom: keyboardInset }]}
contentContainerStyle={styles.bodyContent}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
>
<TextInput
value={title}
@@ -631,7 +678,36 @@ export function EventModal({
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label={t('calendar.alerts.title', 'Reminders')}>
{reminders.length > 0 && (
{onDefaults && (
<View style={{ gap: spacing.xs }}>
<Text style={styles.defaultsCaption}>{t('calendar.alerts.calendar_defaults', 'Calendar defaults')}</Text>
{defaultReminders.length === 0 && (
<Text style={styles.defaultsCaption}>{t('calendar.alerts.none', 'No reminders')}</Text>
)}
{defaultReminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore, t)}</Text>
</View>
))}
<Pressable style={styles.addRow} onPress={customizeReminders} accessibilityRole="button">
<Text style={styles.addRowText}>{t('calendar.alerts.customize', 'Customize')}</Text>
</Pressable>
</View>
)}
{!onDefaults && !event && (
<Pressable
style={styles.addRow}
onPress={() => {
setUseDefaults(true);
setReminderPickerOpen(false);
setCustomReminderOpen(false);
}}
accessibilityRole="button"
>
<Text style={styles.addRowText}>{t('calendar.alerts.use_defaults', 'Use calendar defaults')}</Text>
</Pressable>
)}
{!onDefaults && reminders.length > 0 && (
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
@@ -649,7 +725,7 @@ export function EventModal({
))}
</View>
)}
{availablePresets.length > 0 && (
{!onDefaults && availablePresets.length > 0 && (
<Pressable
style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)}
@@ -723,7 +799,7 @@ export function EventModal({
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
style={styles.fieldInput}
/>
</Section>
@@ -736,7 +812,7 @@ export function EventModal({
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.fieldButton}
style={styles.fieldInput}
/>
</Section>
@@ -944,6 +1020,19 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
// A text field in the field frame. Without a colour of its own a
// TextInput draws black, invisible on the dark background (#82).
fieldInput: {
minHeight: 40,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
...typography.body,
color: c.text,
},
limitRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.xs },
limitText: { ...typography.caption, color: c.warning, flex: 1 },
@@ -999,6 +1088,7 @@ function makeStyles(c: ThemePalette) {
paddingVertical: spacing.sm,
},
addRowText: { ...typography.body, color: c.primary },
defaultsCaption: { ...typography.caption, color: c.textMuted },
popover: {
borderWidth: 1,
@@ -5,7 +5,7 @@ import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
ShieldCheck, ShieldAlert, ChevronDown,
} from '../icons';
import { format, parseISO } from 'date-fns';
import { format, isSameDay } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -17,9 +17,11 @@ import {
calendarInvitationKey,
findCalendarAttachment,
findParticipantByEmail,
getInvitationDisplayRange,
getInvitationMethod,
getInvitationTrustAssessment,
getOrganizerName,
invitationZoneLabel,
buildReplyTo,
type InvitationMethod,
type InvitationTrustAssessment,
@@ -65,7 +67,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
const locale = useLocaleStore((s) => s.locale);
const dateLocale = getDateFnsLocale(locale);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const calendars = useCalendarStore((s) => s.calendars);
@@ -156,8 +159,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}, [storeEvents, event?.uid, serverMatch]);
const trust = React.useMemo(
() => (event ? getInvitationTrustAssessment(event, email, method) : null),
[event, email, method],
() => (event ? getInvitationTrustAssessment(event, email, method, currentUserEmails) : null),
[event, email, method, currentUserEmails],
);
if (!attachment || !enabled || state === 'error') return null;
@@ -172,13 +175,25 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}
if (!event) return null;
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale })
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale }))
: null;
// In the user's zone, with its name, for an event at a fixed moment; the
// organizer's wall clock otherwise read as a device time (webmail #1252).
const range = getInvitationDisplayRange(event);
let dateLabel: string | null = null;
if (range) {
const time = timePattern(timeFormat);
if (event.showWithoutTime) {
const day = 'EEEE, MMM d, yyyy';
dateLabel = format(range.start, day, { locale: dateLocale })
+ (range.end ? ` – ${format(range.end, day, { locale: dateLocale })}` : '');
} else {
dateLabel = format(range.start, `EEE, MMM d · ${time}`, { locale: dateLocale });
if (range.end) {
dateLabel += ` – ${format(range.end, isSameDay(range.start, range.end) ? time : `EEE, MMM d · ${time}`, { locale: dateLocale })}`;
}
const zone = range.instant ? invitationZoneLabel(range.instant, locale) : null;
if (zone) dateLabel += ` ${zone}`;
}
}
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
@@ -49,8 +49,8 @@ describe('parseAlertOffset / computeFireTime', () => {
);
});
it('fires task alerts relative to due', () => {
const task = { ...event, due: '2026-03-02T09:00:00', alerts: undefined };
it('fires the alerts of a task without a start relative to its due', () => {
const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-03-02T09:00:00', alerts: undefined };
expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe(
new Date('2026-03-02T09:00:00').getTime() - HOUR,
);
@@ -58,11 +58,32 @@ describe('parseAlertOffset / computeFireTime', () => {
});
it('reads a zoned task due in its own time zone', () => {
const task = { ...event, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined };
const task = { ...event, start: undefined as unknown as string, utcStart: undefined, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined };
expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe(
new Date('2026-07-01T14:00:00Z').getTime(),
);
});
// #83 (webmail #1328): start 20:00, due 20:15, five minutes before the start.
it('fires a task alert relative to its start from the start, and one relative to its end from the due', () => {
const task = {
...event, start: '2026-07-01T20:00:00', utcStart: undefined, due: '2026-07-01T20:15:00', timeZone: 'Europe/Berlin', alerts: undefined,
};
expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' })).toBe(
new Date('2026-07-01T17:55:00Z').getTime(),
);
// relativeTo left out means the start.
expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe(
new Date('2026-07-01T17:55:00Z').getTime(),
);
expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' })).toBe(
new Date('2026-07-01T18:10:00Z').getTime(),
);
// The server's UTC start, when it gave one.
expect(computeTaskFireTime({ ...task, utcStart: '2026-07-01T18:00:00Z' }, { '@type': 'OffsetTrigger', offset: '-PT5M' })).toBe(
new Date('2026-07-01T17:55:00Z').getTime(),
);
});
});
describe('getEffectiveAlerts', () => {
@@ -91,6 +112,8 @@ describe('getUpcomingAlerts', () => {
const task = {
...event,
id: 't1',
start: undefined as unknown as string,
utcStart: undefined,
due: '2026-03-01T12:00:00Z',
alerts: { x: { trigger: { '@type': 'OffsetTrigger', offset: 'PT0S' }, action: 'display' } },
};
@@ -109,6 +132,8 @@ describe('getUpcomingAlerts', () => {
const task = {
...untitled,
id: 't1',
start: undefined as unknown as string,
utcStart: undefined,
due: '2026-03-01T12:00:00Z',
alerts: { x: { trigger: { '@type': 'OffsetTrigger' as const, offset: 'PT0S' }, action: 'display' as const } },
};
+42
View File
@@ -8,6 +8,10 @@ import {
preservedAlerts,
reminderFieldsToSave,
withOwnAlertIds,
calendarDefaultAlerts,
defaultAlertIdPrefix,
remindersToDefaultAlerts,
sameReminders,
} from '../calendar-alerts';
import { translate, type MessageParams } from '../../i18n';
import type { Alert } from '../../api/types';
@@ -164,3 +168,41 @@ describe('withOwnAlertIds', () => {
expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']);
});
});
describe('a calendar\'s default reminders (#1209)', () => {
const email = { '@type': 'Alert', action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } as Alert;
it('picks the defaults for timed or all-day events', () => {
const cal = {
defaultAlertsWithTime: { a: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } as Alert },
defaultAlertsWithoutTime: null,
};
expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]);
expect(calendarDefaultAlerts(cal, true)).toEqual({});
expect(calendarDefaultAlerts(undefined, false)).toEqual({});
});
it('writes the defaults under ids of their own and keeps what the list cannot show', () => {
const alerts = remindersToDefaultAlerts([{ minutesBefore: 10 }, { minutesBefore: 60 * 24 }], { 'default-1': email });
expect(alerts['default-1']).toBe(email);
expect(Object.keys(alerts).sort()).toEqual(['default-1', 'default-2', 'default-3']);
expect(alertsToReminders(alerts)).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 60 }, { minutesBefore: 10 }]);
// Emptied: a map Stalwart takes, not null.
expect(remindersToDefaultAlerts([])).toEqual({});
});
it('gives each calendar and list ids no other calendar of the account uses (Stalwart 1.0)', () => {
const b = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, false));
const c = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'acc-2:c', originalId: 'c' }, false));
const bAllDay = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, true));
const ids = [...Object.keys(b), ...Object.keys(c), ...Object.keys(bAllDay)];
expect(new Set(ids).size).toBe(3);
expect(Object.keys(b)).toEqual(['cal-b-timed-1']);
});
it('tells a changed list from the same times in another order', () => {
expect(sameReminders([{ minutesBefore: 5 }, { minutesBefore: 15 }], [{ minutesBefore: 15 }, { minutesBefore: 5 }])).toBe(true);
expect(sameReminders([{ minutesBefore: 5 }], [{ minutesBefore: 10 }])).toBe(false);
expect(sameReminders([], [{ minutesBefore: 10 }])).toBe(false);
});
});
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { editorKeyboardInset } from '../calendar-editor-keyboard';
describe('editorKeyboardInset (#81)', () => {
it('ends the scroll area above the keyboard, past the safe-area inset already kept clear', () => {
expect(editorKeyboardInset(320, 24, 0)).toBe(296);
});
it('shortens a window that resized for the keyboard by nothing more', () => {
expect(editorKeyboardInset(320, 24, 296)).toBe(0);
expect(editorKeyboardInset(320, 24, 400)).toBe(0);
});
it('keeps the full height without a keyboard', () => {
expect(editorKeyboardInset(0, 24, 0)).toBe(0);
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import { feedChanged, feedFields } from '../calendar-feed-diff';
// What CalendarEvent/parse gives for a feed, and what Stalwart 0.16 and 1.0
// give back for the same events once imported (the webmail's
// calendar-subscription-refresh test, from both servers).
const PARSED: Partial<CalendarEvent>[] = [
{ uid: 'e1', title: 'Timed Berlin', description: 'Line one\nLine two', start: '2026-11-10T09:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin', status: 'confirmed', locations: { '5960afaa': { '@type': 'Location', name: 'Room 1' } } as never },
{ uid: 'e3', title: 'All day', start: '2026-11-12T00:00:00', duration: 'P1D', showWithoutTime: true },
{
uid: 'e5', title: 'Weekly', start: '2026-11-02T08:00:00', duration: 'PT45M', timeZone: 'Europe/Berlin',
recurrenceRules: [{ frequency: 'weekly', count: 6, byDay: [{ day: 'mo' }] }] as never,
recurrenceOverrides: {
'2026-11-09T08:00:00': { excluded: true },
'2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', updated: '2026-10-20T10:00:00Z', duration: 'PT45M', title: 'Weekly moved' },
} as never,
},
{ uid: 'e7', title: 'Zero length', description: 'Zero', start: '2026-11-19T12:00:00', timeZone: 'Etc/UTC' },
];
function stored(version: '0.16' | '1.0'): Partial<CalendarEvent>[] {
return PARSED.map((p) => {
const event = { ...p, id: `id-${p.uid}`, calendarIds: { sub: true }, useDefaultAlerts: false } as Partial<CalendarEvent>;
if (p.uid === 'e7') event.duration = 'PT0S';
if (p.uid === 'e5') {
event.recurrenceRules = [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 6, interval: 1, byDay: [{ '@type': 'NDay', day: 'mo' }] }] as never;
event.recurrenceOverrides = (version === '0.16'
// 0.16: its own `updated`, keys in another order.
? { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { title: 'Weekly moved', updated: '2026-10-26T13:31:01Z', duration: 'PT45M', start: '2026-11-16T10:00:00' } }
// 1.0: only what differs from the series.
: { '2026-11-09T08:00:00': { excluded: true }, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved' } }) as never;
}
return event;
});
}
describe('feedChanged', () => {
it.each(['0.16', '1.0'] as const)('finds nothing changed in a feed imported unchanged (%s)', (version) => {
const back = stored(version);
PARSED.forEach((p, i) => expect(feedChanged(p, back[i]), p.uid).toBe(false));
});
it.each(['0.16', '1.0'] as const)('sees a moved event, a new place, a longer series and a moved occurrence (%s)', (version) => {
const back = stored(version);
expect(feedChanged({ ...PARSED[0], start: '2026-11-10T11:00:00' }, back[0])).toBe(true);
expect(feedChanged({ ...PARSED[0], locations: { x: { '@type': 'Location', name: 'Room 2' } } as never }, back[0])).toBe(true);
expect(feedChanged({ ...PARSED[2], recurrenceRules: [{ frequency: 'weekly', count: 8, byDay: [{ day: 'mo' }] }] as never }, back[2])).toBe(true);
expect(feedChanged({
...PARSED[2],
recurrenceOverrides: { ...PARSED[2].recurrenceOverrides, '2026-11-16T08:00:00': { start: '2026-11-16T10:00:00', title: 'Weekly moved again' } } as never,
}, back[2])).toBe(true);
expect(feedChanged({ ...PARSED[1], title: 'All day renamed' }, back[1])).toBe(true);
});
});
describe('feedFields', () => {
it('writes what the feed decides and leaves the alerts alone', () => {
const fields = feedFields({ ...PARSED[3], alerts: { a: { '@type': 'Alert' } } as never });
expect(fields).toMatchObject({ title: 'Zero length', duration: 'PT0S', timeZone: 'Etc/UTC', status: 'confirmed', locations: null });
expect(fields).not.toHaveProperty('alerts');
expect(fields).not.toHaveProperty('excludedRecurrenceRules');
expect(feedFields(PARSED[1])).toMatchObject({ start: '2026-11-12T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true });
});
});
+28 -1
View File
@@ -48,11 +48,38 @@ describe('eventToICS', () => {
expect(ics).not.toContain('DTEND');
});
it('folds long lines at 74 octets', () => {
it('folds long lines at 75 octets', () => {
const ics = eventToICS({ ...event, description: 'x'.repeat(200) });
const long = ics.split('\r\n').filter((l) => l.length > 75);
expect(long).toHaveLength(0);
});
it('folds by octets between characters, so umlauts and emoji survive', () => {
const utf8 = new TextEncoder();
const text = 'Grüße 🎉 '.repeat(30);
const ics = eventToICS({ ...event, description: text });
const lines = ics.split('\r\n');
expect(lines.every((l) => utf8.encode(l).length <= 75)).toBe(true);
// Unfolded, the description reads back whole: no emoji cut in half.
const unfolded = ics.replace(/\r\n /g, '');
expect(unfolded).toContain(`DESCRIPTION:${text}`);
expect(unfolded).not.toContain('�');
expect(lines.some((l) => /[\uD800-\uDBFF]$/.test(l))).toBe(false);
});
it('quotes a name holding a colon, semicolon or comma, and writes no blank one', () => {
const ics = eventToICS({
...event,
participants: {
a: { calendarAddress: 'mailto:alice@example.com', name: 'Ops: Alice' },
b: { calendarAddress: 'mailto:bob@example.com', name: 'Smith, Bob "BB"', roles: { attendee: true } },
c: { calendarAddress: 'mailto:carol@example.com', name: ' ', roles: { attendee: true } },
},
});
expect(ics).toContain('ORGANIZER;CN="Ops: Alice":mailto:alice@example.com');
expect(ics).toContain('ATTENDEE;CN="Smith, Bob \'BB\'";PARTSTAT=NEEDS-ACTION:mailto:bob@example.com');
expect(ics).toContain('ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com');
});
});
describe('helpers', () => {
+108 -2
View File
@@ -1,16 +1,57 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { format } from 'date-fns';
import type { Email } from '../../api/types';
import { useSettingsStore } from '../../stores/settings-store';
import {
calendarInvitationKey,
extractMethodFromContentType,
extractMethodFromRawIcs,
findCalendarAttachment,
getEmailAuthenticationResults,
getInvitationDisplayRange,
getInvitationMethod,
getInvitationTrustAssessment,
invitationZoneLabel,
parseAuthenticationResults,
} from '../calendar-invitation';
describe('getInvitationDisplayRange (webmail #1252)', () => {
afterEach(() => {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
});
const shown = (d: Date | null | undefined) => (d ? format(d, 'yyyy-MM-dd HH:mm') : null);
it('shows an organizer\'s wall clock in the user\'s zone', () => {
useSettingsStore.setState({ calendarTimeZone: 'Europe/Warsaw' });
// As CalendarEvent/parse gives an Exchange invitation from London: no utcStart.
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', duration: 'PT25M' });
expect(shown(range?.start)).toBe('2026-10-20 16:00');
expect(shown(range?.end)).toBe('2026-10-20 16:25');
expect(range?.instant?.toISOString()).toBe('2026-10-20T14:00:00.000Z');
expect(invitationZoneLabel(range!.instant!, 'en')).toBeTruthy();
});
it('takes the server\'s UTC start when it has one', () => {
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', utcStart: '2026-10-20T14:00:00Z', utcEnd: '2026-10-20T15:00:00Z' });
expect(shown(range?.start)).toBe('2026-10-20 10:00');
expect(shown(range?.end)).toBe('2026-10-20 11:00');
});
it('leaves floating times and all-day dates as written', () => {
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
const floating = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', duration: 'PT1H' });
expect(shown(floating?.start)).toBe('2026-10-20 15:00');
expect(floating?.instant).toBeUndefined();
const oneDay = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P1D', showWithoutTime: true });
expect(shown(oneDay?.start)).toBe('2026-10-20 00:00');
expect(oneDay?.end).toBeNull();
// The stored end is exclusive: three days end on the third.
const threeDays = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P3D', showWithoutTime: true });
expect(shown(threeDays?.end)).toBe('2026-10-22 00:00');
});
});
const request = {
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
@@ -63,7 +104,7 @@ describe('authentication results', () => {
'mx.example.com; dkim=pass header.d=example.com header.s=sel; spf=pass smtp.mailfrom=example.com; dmarc=pass header.from=example.com',
);
expect(parsed.spf?.result).toBe('pass');
expect(parsed.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'sel' });
expect(parsed.dkim).toMatchObject({ result: 'pass', domain: 'example.com', selector: 'sel' });
expect(parsed.dmarc?.result).toBe('pass');
});
@@ -77,6 +118,26 @@ describe('authentication results', () => {
expect(getEmailAuthenticationResults(e)?.dmarc?.result).toBe('fail');
expect(getEmailAuthenticationResults(email({}))).toBeNull();
});
// A sender controls the envelope address, comments and every header below
// the receiving server's own.
it('takes no result a sender wrote into a property, a comment or a lower header', () => {
const inEnvelope = parseAuthenticationResults(
'mx.example.org; spf=none smtp.mailfrom="dkim=pass;dmarc=pass"@forger.example (dkim=pass header.d=example.com)',
);
expect(inEnvelope.dkim).toBeUndefined();
expect(inEnvelope.dmarc).toBeUndefined();
const lower = email({
from: [{ email: 'alice@example.com' }],
headers: [
{ name: 'Authentication-Results', value: 'mx.example.org; spf=none smtp.mailfrom=forger.example; dmarc=none' },
{ name: 'Authentication-Results', value: 'forged; dkim=pass header.d=example.com; dmarc=pass header.from=example.com' },
],
});
expect(getEmailAuthenticationResults(lower)).toMatchObject({ dmarc: { result: 'none' } });
expect(getEmailAuthenticationResults(lower)?.dkim).toBeUndefined();
expect(getInvitationTrustAssessment(request, lower, 'request').level).toBe('caution');
});
});
describe('getInvitationTrustAssessment', () => {
@@ -113,6 +174,51 @@ describe('getInvitationTrustAssessment', () => {
expect(getInvitationTrustAssessment(request, e, 'request').reason).toBe('authentication_missing');
expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted');
});
// #1374: a forger passes SPF for an envelope domain of their own, or signs
// with their own key, and writes the organizer's address in From.
const forged = (results: string) => email({
from: [{ email: 'alice@example.com' }],
headers: [{ name: 'Authentication-Results', value: `mx.example.org; ${results}` }],
});
it('trusts only a pass aligned with the From domain', () => {
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@forger.example; dmarc=none header.from=example.com'), 'request'))
.toMatchObject({ level: 'caution', reason: 'authentication_missing' });
expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example header.s=k1; dmarc=none'), 'request').level)
.toBe('caution');
// A HELO pass never vouches for the sender.
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.helo=example.com'), 'request').level).toBe('caution');
// Aligned: the same domain, or a parent or child of it.
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@mail.example.com; dmarc=none'), 'request').level)
.toBe('trusted');
expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example; dkim=pass header.d=example.com'), 'request').level)
.toBe('trusted');
});
it('warns about a forged organizer whose mail passes only for the forger\'s domain', () => {
const e = email({
from: [{ email: 'mallory@forger.example' }],
headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=forger.example; dmarc=none' }],
});
// Aligned with its own From, so only a caution about the mismatch.
expect(getInvitationTrustAssessment(request, e, 'request')).toMatchObject({ level: 'caution', reason: 'sender_mismatch' });
const unaligned = email({
from: [{ email: 'mallory@forger.example' }],
headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=other.example; dmarc=none' }],
});
expect(getInvitationTrustAssessment(request, unaligned, 'request')).toMatchObject({ level: 'warning', reason: 'sender_mismatch_unverified' });
});
it('does not flag a colleague\'s invitation that never left the server', () => {
const colleague = email({ from: [{ email: 'alice@example.com' }] });
expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@example.com']).level).toBe('trusted');
// From outside, or with a header saying it came in from outside: as before.
expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@elsewhere.example']).reason).toBe('authentication_missing');
expect(getInvitationTrustAssessment(request, forged('spf=none smtp.mailfrom=example.com'), 'request', ['bob@example.com']).reason)
.toBe('authentication_missing');
});
});
describe('calendarInvitationKey', () => {
@@ -349,6 +349,17 @@ describe('answers on save (Stalwart 0.16 and 1.0)', () => {
]);
});
it('writes no blank names', () => {
const map = buildParticipantMap({ name: '', email: 'me@example.com' }, [
{ name: ' ', email: 'bob@example.com' },
{ name: ' Carol ', email: 'carol@example.com' },
]);
const byEmail = Object.fromEntries(Object.values(map).map((p) => [p.email, p]));
expect(byEmail['me@example.com']).not.toHaveProperty('name');
expect(byEmail['bob@example.com']).not.toHaveProperty('name');
expect(byEmail['carol@example.com'].name).toBe('Carol');
});
it('tells whether anyone but the organizer answered', () => {
expect(hasAnsweredAttendees(meeting.participants)).toBe(true);
expect(hasAnsweredAttendees(participantsAskedAgain(meeting))).toBe(false);
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import { eventMatches, orderSearchResults, searchFilter, searchTerms } from '../calendar-search';
const ev = (partial: Partial<CalendarEvent>): CalendarEvent => ({
id: 'e', uid: 'u', title: 'Event', calendarIds: { c: true }, start: '2026-03-02T09:00:00', duration: 'PT1H',
...partial,
} as CalendarEvent);
describe('calendar search', () => {
it('asks the server for every word', () => {
expect(searchTerms(' Team Sync ')).toEqual(['team', 'sync']);
expect(searchFilter(['team'])).toEqual({ text: 'team' });
expect(searchFilter(['team', 'sync'])).toEqual({ operator: 'AND', conditions: [{ text: 'team' }, { text: 'sync' }] });
expect(searchFilter([])).toBeNull();
});
it('matches the loaded events by title, place, description and participants', () => {
const event = ev({
title: 'Quarterly review',
description: 'Budget',
locations: { l: { '@type': 'Location', name: 'Room Atlas' } } as never,
participants: { p: { calendarAddress: 'mailto:dana@example.com', name: 'Dana' } } as never,
});
expect(eventMatches(event, searchTerms('review atlas'))).toBe(true);
expect(eventMatches(event, searchTerms('budget dana@example'))).toBe(true);
expect(eventMatches(event, searchTerms('review lunch'))).toBe(false);
expect(eventMatches(event, [])).toBe(false);
});
it('shows a series once, by its next occurrence, and the server\'s finds the window lacks', () => {
const now = new Date('2026-03-10T12:00:00').getTime();
const occurrence = (day: string) => ev({ id: `s:${day}`, uid: 'series', title: 'Standup', start: `2026-03-${day}T09:00:00`, recurrenceId: `2026-03-${day}T09:00:00` });
const local = [occurrence('09'), occurrence('16'), occurrence('11')];
const server = [
ev({ id: 'series-base', uid: 'series', title: 'Standup', start: '2026-01-05T09:00:00', recurrenceRules: [{ frequency: 'weekly' }] as never }),
ev({ id: 'old', uid: 'old', title: 'Standup retro', start: '2025-11-01T09:00:00' }),
];
const { upcoming, past } = orderSearchResults(local, server, now);
expect(upcoming.map((e) => e.id)).toEqual(['s:11']);
expect(past.map((e) => e.id)).toEqual(['old']);
});
});
+12
View File
@@ -332,6 +332,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => {
timeZone: 'Europe/Berlin',
utcStart: '2026-10-25T11:51:00Z',
utcEnd: '2026-10-25T12:16:00Z',
duration: 'PT25M',
});
expect(getTimedEventBoundsForDay(fallBack, new Date(2026, 9, 25))).toMatchObject({
startMinutes: 12 * 60 + 51,
@@ -344,6 +345,7 @@ describe('getTimedEventBoundsForDay on DST change days', () => {
timeZone: 'Europe/Berlin',
utcStart: '2026-03-29T07:00:00Z',
utcEnd: '2026-03-29T08:00:00Z',
duration: 'PT1H',
});
expect(getTimedEventBoundsForDay(springForward, new Date(2026, 2, 29))).toMatchObject({
startMinutes: 9 * 60,
@@ -354,3 +356,13 @@ describe('getTimedEventBoundsForDay on DST change days', () => {
}
});
});
describe('getEventEndDate', () => {
// #837: Stalwart stores a DTEND equal to DTSTART as no duration and ends
// the event at 23:59:59 of its day.
it('ends a timed event without a duration at its start, whatever utcEnd says', () => {
const zero = ev({ start: '2026-03-02T09:00:00', utcStart: '2026-03-02T09:00:00Z', utcEnd: '2026-03-02T23:59:59Z' });
delete (zero as Partial<CalendarEvent>).duration;
expect(getEventEndDate(zero).getTime()).toBe(getEventStartDate(zero).getTime());
});
});
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import { expandRecurringEvents } from '../recurrence-expansion';
// Daily expansion advanced a Date across the device's DST gap and kept the
// shifted time (02:30 became 03:30, midnight became 01:00); the implicit
// byHour then rejected every later day, so the series ended at the gap.
function daily(start: string, allDay: boolean, timeZone?: string): CalendarEvent {
return {
id: 'E1', uid: 'u1', title: 'Daily', calendarIds: { c: true },
start, duration: allDay ? 'P1D' : 'PT30M', showWithoutTime: allDay,
...(timeZone ? { timeZone } : {}),
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
} as unknown as CalendarEvent;
}
let originalTZ: string | undefined;
beforeEach(() => {
originalTZ = process.env.TZ;
});
afterEach(() => {
process.env.TZ = originalTZ;
});
describe('daily recurrence across a DST gap', () => {
it('keeps a 02:30 series going past the spring-forward night (Berlin)', () => {
process.env.TZ = 'Europe/Berlin';
const out = expandRecurringEvents([daily('2026-03-01T02:30:00', false, 'Europe/Berlin')], '2026-03-01T00:00:00', '2026-04-01T00:00:00');
expect(out).toHaveLength(31);
expect(out.at(-1)!.start.slice(0, 16)).toBe('2026-03-31T02:30');
});
it('keeps an all-day series going after a midnight gap (Santiago)', () => {
process.env.TZ = 'America/Santiago';
const out = expandRecurringEvents([daily('2026-08-20T00:00:00', true)], '2026-09-07T00:00:00', '2026-09-14T00:00:00');
expect(out).toHaveLength(7);
expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00'));
});
});
+10
View File
@@ -83,6 +83,16 @@ export function computeTaskFireTime(task: CalendarEvent, trigger: Alert['trigger
if (trigger.offset === undefined) return null;
const offsetMs = parseAlertOffset(trigger.offset);
if (offsetMs === null) return null;
// RFC 8984 §4.5.2: relativeTo defaults to "start", which for a Task is its
// start; "end" is its due. A task without a start counts from its due
// (#83, webmail #1328).
if (trigger.relativeTo !== 'end' && task.start) {
const start = task.utcStart
? new Date(task.utcStart)
// The start read like a due: in the task's zone, or a calendar date.
: getTaskDueDate({ due: task.start, timeZone: task.timeZone, showWithoutTime: task.showWithoutTime });
if (start && !Number.isNaN(start.getTime())) return start.getTime() + offsetMs;
}
const due = getTaskDueDate(task);
if (!due) return null;
return due.getTime() + offsetMs;
+52 -1
View File
@@ -1,4 +1,4 @@
import type { Alert, CalendarEvent } from '../api/types';
import type { Alert, Calendar, CalendarEvent } from '../api/types';
import { formatMessage, type MessageParams } from '../i18n/format';
// A reminder, expressed as "minutes before the event start". 0 means "at the
@@ -130,6 +130,57 @@ function reminderMinutes(reminders: Reminder[]): number[] {
return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a);
}
/** Whether two reminder lists remind at the same times. */
export function sameReminders(a: Reminder[], b: Reminder[]): boolean {
const x = reminderMinutes(a);
const y = reminderMinutes(b);
return x.length === y.length && x.every((minutes, i) => minutes === y[i]);
}
// ─── A calendar's default alerts (#1209) ─────────────────
/** The alerts `calendar` gives an event with a time, or an all-day one, that uses its defaults. */
export function calendarDefaultAlerts(
calendar: Pick<Calendar, 'defaultAlertsWithTime' | 'defaultAlertsWithoutTime'> | null | undefined,
allDay: boolean,
): Record<string, Alert> {
return (allDay ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime) ?? {};
}
/**
* A calendar's default alerts as the editor's reminders set them, with the
* ones it can't show (`preserved`) kept as they were. Under ids of their own,
* `${idPrefix}-1` and on: Stalwart 1.0 refuses default alert ids that another
* calendar of the account uses ("Default alert ids must be unique across all
* calendars in the account."), so the prefix names the calendar and the
* list; it also shows them in an event that uses them under the same ids,
* where an event's own reminder-N alerts must not meet them. An emptied list
* is `{}`: Stalwart refuses null for these maps.
*/
/** The id prefix of `calendar`'s default alerts for timed or all-day events, unique in its account. */
export function defaultAlertIdPrefix(calendar: Pick<Calendar, 'id' | 'originalId'>, allDay: boolean): string {
return `cal-${calendar.originalId || calendar.id}-${allDay ? 'allday' : 'timed'}`;
}
export function remindersToDefaultAlerts(
reminders: Reminder[],
preserved: Record<string, Alert> = {},
idPrefix = 'default',
): Record<string, Alert> {
const alerts: Record<string, Alert> = { ...preserved };
let n = 0;
for (const minutesBefore of reminderMinutes(reminders)) {
let key = `${idPrefix}-${++n}`;
while (key in alerts) key = `${idPrefix}-${++n}`;
alerts[key] = {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(minutesBefore), relativeTo: 'start' },
action: 'display',
};
}
return alerts;
}
/**
* What an editor save writes for the reminders: nothing while they are the
* ones the event had, so an event that uses its calendar's default alerts
+12
View File
@@ -0,0 +1,12 @@
/**
* How far above its normal bottom the event editor's scroll area has to end
* so the keyboard covers none of it (#81). `obstruction` is how much of the
* screen bottom the keyboard covers (use-keyboard-obstruction),
* `bottomInset` the safe-area inset the editor already keeps clear, and
* `resizedBy` how much the window itself shrank for the keyboard: an
* edge-to-edge Android dialog does not resize at all, a window that does
* must not be shortened twice.
*/
export function editorKeyboardInset(obstruction: number, bottomInset: number, resizedBy: number): number {
return Math.max(0, Math.round(obstruction - bottomInset - Math.max(0, resizedBy)));
}
+77
View File
@@ -0,0 +1,77 @@
import type { CalendarEvent } from '../api/types';
import { normalizeAllDayDuration } from './calendar-utils';
/**
* What a subscribed iCal feed decides about an event it still carries, in
* the form the subscription import writes it. Refreshing a subscription
* writes these, and only when the feed changed one of them: alerts,
* participants and anything else stay as the server holds them. Mirrors the
* webmail's lib/calendar-feed-diff.
*/
export function feedFields(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
const allDay = !!event.showWithoutTime;
return {
title: event.title ?? '',
description: event.description ?? '',
start: allDay && event.start ? `${event.start.slice(0, 10)}T00:00:00` : event.start,
// CalendarEvent/parse drops a DTEND equal to DTSTART, and the servers
// store that event with PT0S.
duration: allDay ? normalizeAllDayDuration(event.duration) : (event.duration || 'PT0S'),
timeZone: allDay ? null : (event.timeZone ?? null),
showWithoutTime: allDay,
status: event.status ?? 'confirmed',
locations: event.locations ?? null,
virtualLocations: event.virtualLocations ?? null,
recurrenceRules: event.recurrenceRules ?? null,
// Not excludedRecurrenceRules: 0.16 refuses that property even as null,
// and RFC 5545 has retired EXRULE.
recurrenceOverrides: event.recurrenceOverrides ?? null,
};
}
const RULE_DEFAULTS: Record<string, unknown> = { interval: 1, rscale: 'gregorian', skip: 'omit', firstDayOfWeek: 'mo' };
/** JSON with sorted keys, without `@type` and a rule's default values. */
function canonical(value: unknown): string {
return JSON.stringify(value, (_key, v) => {
if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
const out: Record<string, unknown> = {};
for (const k of Object.keys(v).sort()) {
if (k === '@type' || RULE_DEFAULTS[k] === (v as Record<string, unknown>)[k]) continue;
out[k] = (v as Record<string, unknown>)[k];
}
return out;
});
}
// What an override may change and both servers give back as is. 0.16 adds
// `updated` (its own time) and repeats the base duration; 1.0 leaves out
// what equals the series, so a missing value is the series' value.
const OVERRIDE_FIELDS = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const;
function overridesKey(fields: Partial<CalendarEvent>): string {
const out: Record<string, unknown> = {};
for (const [recurrenceId, patch] of Object.entries(fields.recurrenceOverrides ?? {})) {
const p = (patch ?? {}) as Record<string, unknown>;
out[recurrenceId] = p.excluded
? 'excluded'
: OVERRIDE_FIELDS.map((k) => (k in p ? p[k] : fields[k]) ?? null);
}
return canonical(out);
}
function names(map: Record<string, { name?: string; uri?: string }> | null | undefined, key: 'name' | 'uri'): string[] {
return Object.values(map ?? {}).map((entry) => entry?.[key] ?? '').sort();
}
/** Whether the feed's copy of an event differs from the stored one in what the feed decides. */
export function feedChanged(incoming: Partial<CalendarEvent>, stored: Partial<CalendarEvent>): boolean {
const a = feedFields(incoming);
const b = feedFields(stored);
const scalar = ['title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'status'] as const;
if (scalar.some((k) => (a[k] ?? null) !== (b[k] ?? null))) return true;
if (canonical(names(a.locations as never, 'name')) !== canonical(names(b.locations as never, 'name'))) return true;
if (canonical(names(a.virtualLocations as never, 'uri')) !== canonical(names(b.virtualLocations as never, 'uri'))) return true;
if (canonical(a.recurrenceRules) !== canonical(b.recurrenceRules)) return true;
return overridesKey(a) !== overridesKey(b);
}
+29 -10
View File
@@ -4,19 +4,38 @@ import { t } from '../stores/locale-store';
// Port of the webmail's lib/calendar-ics-export.ts: serialise one event as
// an RFC 5545 VCALENDAR so it can be shared as a .ics file.
const MAX_LINE_OCTETS = 74;
const MAX_LINE_OCTETS = 75;
const utf8 = new TextEncoder();
function foldLine(line: string): string {
if (line.length <= MAX_LINE_OCTETS) return line;
const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)];
let pos = MAX_LINE_OCTETS;
while (pos < line.length) {
chunks.push(' ' + line.slice(pos, pos + MAX_LINE_OCTETS - 1));
pos += MAX_LINE_OCTETS - 1;
// RFC 5545 §3.1: lines of at most 75 octets, folded between characters. Cut
// by UTF-16 units, a line with umlauts ran past 75 octets and a fold could
// split an emoji, which the file then held as two U+FFFD.
export function foldLine(line: string): string {
const chunks: string[] = [];
let chunk = '';
let octets = 0;
for (const char of line) {
const size = utf8.encode(char).length;
if (octets + size > MAX_LINE_OCTETS) {
chunks.push(chunk);
chunk = ' ';
octets = 1;
}
chunk += char;
octets += size;
}
chunks.push(chunk);
return chunks.join('\r\n');
}
// RFC 5545 §3.2: a parameter value holding ":", ";" or "," is quoted, and
// can't hold a DQUOTE or a line break. Escaped like TEXT, "CN=Ops: Alice"
// ended the parameters at the colon and the backslashes stayed in the name.
export function paramValue(value: string): string {
const clean = value.replace(/"/g, "'").replace(/[\r\n]+/g, ' ');
return /[:;,]/.test(clean) ? `"${clean}"` : clean;
}
// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values.
export function escapeText(value: string): string {
return value
@@ -157,14 +176,14 @@ export function eventToICS(event: CalendarEvent): string {
);
const orgEmail = organizer ? participantEmail(organizer) : organizerEmail;
if (orgEmail) {
const cn = organizer?.name ? `;CN=${escapeText(organizer.name)}` : '';
const cn = organizer?.name?.trim() ? `;CN=${paramValue(organizer.name.trim())}` : '';
lines.push(`ORGANIZER${cn}:mailto:${orgEmail}`);
}
for (const p of Object.values(event.participants)) {
if (p === organizer) continue;
const email = participantEmail(p);
if (!email) continue;
const cn = p.name ? `;CN=${escapeText(p.name)}` : '';
const cn = p.name?.trim() ? `;CN=${paramValue(p.name.trim())}` : '';
const partstat = p.participationStatus
? `;PARTSTAT=${p.participationStatus.toUpperCase()}`
: ';PARTSTAT=NEEDS-ACTION';
+274 -39
View File
@@ -1,4 +1,6 @@
import type { CalendarEvent, Participant, Email, Attachment, BodyPart, EmailAddress } from '../api/types';
import { parseDuration, parseLocalDateTime } from './calendar-utils';
import { getEffectiveTimeZone, isValidTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
// ─── Address helpers ─────────────────────────────────────
@@ -266,42 +268,170 @@ export function calendarInvitationKey(
// ─── Authentication-Results / trust ──────────────────────
interface SpfCheck {
result: string;
identity?: string;
domain?: string;
}
interface DkimCheck {
result: string;
domain?: string;
selector?: string;
}
export interface AuthenticationResults {
spf?: { result: string; domain?: string };
dkim?: { result: string; domain?: string; selector?: string };
/** The headline result; `all` every identity checked (MAIL FROM, HELO). */
spf?: SpfCheck & { all?: SpfCheck[] };
/** The first signature; `all` every one the header lists. */
dkim?: DkimCheck & { all?: DkimCheck[] };
dmarc?: { result: string; domain?: string; policy?: string };
}
/** Parse an Authentication-Results header into SPF / DKIM / DMARC results. */
export function parseAuthenticationResults(header: string): AuthenticationResults {
const results: AuthenticationResults = {};
// A header can carry several SPF results (HELO and MAIL FROM); the MAIL FROM
// identity is primary, another identity may only escalate to a failure.
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
const severity: Record<string, number> = {
fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0,
};
const spf: Array<{ result: string; identity?: string; domain?: string }> = [];
let m: RegExpExecArray | null;
while ((m = spfRegex.exec(header)) !== null) {
spf.push({ result: m[1].toLowerCase(), identity: m[2], domain: m[3] });
interface ResInfo {
method: string;
result: string;
props: Record<string, string>;
}
// The parser below is a port of parseAuthenticationResults in
// lib/email-headers.ts on the reading branch (fx/read), which this branch
// can't import yet: switch to that export (and its
// hasAlignedAuthenticationPass) once both are merged.
/**
* Split one Authentication-Results header into its `;`-separated parts
* (RFC 8601), dropping comments. A `;` inside a quoted string or a comment
* does not split: both can carry sender-chosen text such as the envelope
* address.
*/
function splitResinfo(header: string): string[] {
const parts: string[] = [];
let current = '';
let depth = 0;
let quoted = false;
for (let i = 0; i < header.length; i++) {
const c = header[i];
if (c === '\\' && (quoted || depth > 0)) {
if (depth === 0) current += c + (header[i + 1] ?? '');
i++;
continue;
}
if (quoted) {
current += c;
if (c === '"') quoted = false;
continue;
}
if (c === '(') {
depth++;
continue;
}
if (depth > 0) {
if (c === ')' && --depth === 0) current += ' ';
continue;
}
if (c === '"') quoted = true;
if (c === ';') {
parts.push(current);
current = '';
continue;
}
current += c;
}
parts.push(current);
return parts.map((part) => part.trim()).filter(Boolean);
}
const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i;
const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y;
/**
* Read one resinfo: the method must open the part, so a `dmarc=pass` that
* appears inside a property value (say an envelope local part in
* `smtp.mailfrom=`) is never taken for a result.
*/
function parseResinfo(part: string): ResInfo | null {
const match = METHOD_RE.exec(part);
if (!match) return null;
const props: Record<string, string> = {};
const rest = part.slice(match[0].length);
PROP_RE.lastIndex = 0;
let prop: RegExpExecArray | null;
while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) {
const key = prop[1].toLowerCase();
let value = prop[2];
if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1');
if (!(key in props)) props[key] = value;
}
return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props };
}
const SPF_SEVERITY: Record<string, number> = {
fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0,
};
const DMARC_SEVERITY: Record<string, number> = { fail: 3, permerror: 2, temperror: 2, none: 1, pass: 0 };
/**
* Parse Authentication-Results headers, in message order, into SPF, DKIM
* and DMARC results. The topmost header is the receiving server's own;
* anything below it may have been written by the sender, so DKIM and DMARC
* come from the topmost header only, and the others can only escalate SPF
* to a failure, never supply a pass. A method counts only where it opens a
* `;`-separated part: matched anywhere in the header, a `dkim=pass` written
* into a comment or an envelope address passed for a result.
*/
export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults {
const results: AuthenticationResults = {};
const list = typeof headers === 'string' ? [headers] : headers;
const perHeader = list.map((header) => splitResinfo(header)
.map(parseResinfo)
.filter((info): info is ResInfo => info !== null));
const own = perHeader[0] ?? [];
const foreign = perHeader.slice(1).flat();
const severity = (r: string) => SPF_SEVERITY[r] ?? -1;
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
const toSpfCheck = (info: ResInfo): SpfCheck => {
const identity = info.props['smtp.mailfrom'] !== undefined
? 'mailfrom'
: info.props['smtp.helo'] !== undefined ? 'helo' : undefined;
return { result: info.result, identity, domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined };
};
const spf: SpfCheck[] = [
...own.filter((info) => info.method === 'spf').map(toSpfCheck),
...foreign.filter((info) => info.method === 'spf').map(toSpfCheck).filter((e) => isFailure(e.result)),
];
if (spf.length > 0) {
// The MAIL FROM identity is primary; another one (HELO) may only
// escalate the headline to a failure.
let primary = spf.find((e) => e.identity === 'mailfrom') ?? spf[0];
for (const cur of spf) {
const s = severity[cur.result] ?? -1;
if (s >= severity.temperror && s > (severity[primary.result] ?? -1)) primary = cur;
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) primary = cur;
}
results.spf = { result: primary.result, domain: primary.domain };
results.spf = { ...primary, all: spf };
}
const dkim = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/);
if (dkim) results.dkim = { result: dkim[1].toLowerCase(), domain: dkim[2], selector: dkim[3] };
const dmarc = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/);
if (dmarc) results.dmarc = { result: dmarc[1].toLowerCase(), domain: dmarc[2], policy: dmarc[3] };
// A message can carry several signatures (the sender's and a mailing
// list's or a sending service's): alignment looks at each.
const dkim: DkimCheck[] = own
.filter((info) => info.method === 'dkim')
.map((info) => ({ result: info.result, domain: info.props['header.d'], selector: info.props['header.s'] }));
if (dkim.length > 0) results.dkim = { ...dkim[0], all: dkim };
// One DMARC verdict per message; should the header carry several, the
// most severe stands.
const dmarc = own
.filter((info) => info.method === 'dmarc')
.reduce<ResInfo | undefined>(
(worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst),
undefined,
);
if (dmarc) results.dmarc = { result: dmarc.result, domain: dmarc.props['header.from'], policy: dmarc.props['policy.dmarc'] };
return results;
}
/** Authentication results of an email, derived from its raw headers. */
/** Authentication results of an email, from its Authentication-Results headers in message order. */
export function getEmailAuthenticationResults(
email?: Pick<Email, 'headers'> | null,
): AuthenticationResults | null {
@@ -310,22 +440,46 @@ export function getEmailAuthenticationResults(
.filter((h) => h.name.toLowerCase() === 'authentication-results')
.map((h) => h.value);
if (values.length === 0) return null;
// Merge: the first (outermost, added by our own server) header wins per
// mechanism; later ones only fill gaps.
const merged: AuthenticationResults = {};
for (const value of values) {
const parsed = parseAuthenticationResults(value);
if (!merged.spf && parsed.spf) merged.spf = parsed.spf;
if (!merged.dkim && parsed.dkim) merged.dkim = parsed.dkim;
if (!merged.dmarc && parsed.dmarc) merged.dmarc = parsed.dmarc;
}
return merged;
return parseAuthenticationResults(values);
}
function hasVerifiedAuthentication(auth?: AuthenticationResults | null): boolean {
return Boolean(
auth?.dmarc?.result === 'pass' || auth?.dkim?.result === 'pass' || auth?.spf?.result === 'pass',
);
function domainOf(address: string | undefined): string | undefined {
if (!address) return undefined;
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
}
/**
* Whether `candidate` is the From domain or a parent or child of it: the
* relaxed alignment DMARC applies, without a public-suffix list.
*/
function alignedWith(fromDomain: string, candidate: string | undefined): boolean {
const other = domainOf(candidate);
if (!other) return false;
return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`);
}
/**
* Whether the receiving server's checks tie the message to the domain of its
* From address: a DMARC pass, or an SPF (MAIL FROM, never HELO) or DKIM pass
* for a domain aligned with it. A bare pass says nothing about the visible
* sender: anyone passes SPF for an envelope domain of their own, or signs
* with their own key, and still writes the organizer's address in From; with
* no DMARC policy on that domain the verdict is `dmarc=none` (#1374). Mirrors
* the webmail's hasAlignedAuthenticationPass.
*/
export function hasAlignedAuthenticationPass(
auth: AuthenticationResults | null | undefined,
fromEmail: string | null | undefined,
): boolean {
if (!auth || !fromEmail) return false;
const fromDomain = domainOf(fromEmail);
if (!fromDomain) return false;
if (auth.dmarc?.result === 'pass') return true;
const spf = auth.spf?.all ?? (auth.spf ? [auth.spf] : []);
if (spf.some((e) => e.identity !== 'helo' && e.result === 'pass' && alignedWith(fromDomain, e.domain))) return true;
const dkim = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []);
return dkim.some((e) => e.result === 'pass' && alignedWith(fromDomain, e.domain));
}
function hasAuthenticationFailure(auth?: AuthenticationResults | null): boolean {
@@ -361,19 +515,28 @@ export interface InvitationTrustAssessment {
* email plus whether the sender matches the event's organizer. A spoofed
* invitation from an unauthenticated sender must not look like a real one.
* Port of the webmail's getInvitationTrustAssessment.
*
* `ownAddresses` are the reader's own addresses. Mail from another user on
* the reader's domains that carries no Authentication-Results header at all
* was handed in by a signed-in user rather than received from outside:
* Stalwart adds the header to everything that arrives on port 25 (#1374).
*/
export function getInvitationTrustAssessment(
event: Partial<CalendarEvent>,
email?: Pick<Email, 'from' | 'replyTo' | 'headers'> | null,
method: InvitationMethod = inferInvitationMethod(event),
ownAddresses: readonly string[] = [],
): InvitationTrustAssessment {
const organizerEmail = getOrganizerEmail(event);
const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo);
const auth = getEmailAuthenticationResults(email);
const verified = hasVerifiedAuthentication(auth);
const verified = hasAlignedAuthenticationPass(auth, getPrimaryAddressEmail(email?.from));
const failed = hasAuthenticationFailure(auth);
const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail);
const expectsAuthenticatedTransport = method !== 'unknown';
const senderDomain = domainOf(senderEmail ?? undefined);
const internal = Boolean(email && !auth && senderDomain
&& ownAddresses.some((address) => domainOf(address) === senderDomain));
const expectsAuthenticatedTransport = method !== 'unknown' && !internal;
if (senderMismatch && (failed || !verified)) {
return { level: 'warning', reason: 'sender_mismatch_unverified', senderEmail, organizerEmail };
@@ -389,3 +552,75 @@ export function getInvitationTrustAssessment(
}
return { level: 'trusted', reason: null, senderEmail, organizerEmail };
}
// ─── When ────────────────────────────────────────────────
/** What the invitation banner shows as an event's time. */
export interface InvitationDisplayRange {
/** Display dates (see toDisplayDate): their local fields are what to show. */
start: Date;
end: Date | null;
/**
* The real start instant, for an event at a fixed moment (a UTC time or a
* zoned wall clock) shown in the user's zone: worth a zone label. Unset
* for all-day and floating events, which read the same everywhere.
*/
instant?: Date;
}
const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
/**
* The banner's start and end of an invitation. CalendarEvent/parse gives
* `start` as a wall clock in `timeZone` and no `utcStart`, so the start has
* to be resolved in that zone and shown in the user's: read as a device
* time, it showed the organizer's clock without saying so (webmail #1252).
* An all-day event gets its last day (the end is exclusive) or none for a
* single day; a floating time stays as written.
*/
export function getInvitationDisplayRange(
event: Partial<Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime' | 'utcStart' | 'utcEnd'>>,
): InvitationDisplayRange | null {
if (!event.start) return null;
const durationMs = parseDuration(event.duration);
if (event.showWithoutTime) {
const start = parseLocalDateTime(event.start.slice(0, 10));
if (isNaN(start.getTime())) return null;
const days = Math.round(durationMs / 86_400_000);
if (days <= 1) return { start, end: null };
const last = new Date(start);
last.setDate(last.getDate() + days - 1);
return { start, end: last };
}
let instant: Date | null = null;
if (event.utcStart) instant = new Date(event.utcStart);
else if (HAS_UTC_OFFSET.test(event.start)) instant = new Date(event.start);
else if (event.timeZone && isValidTimeZone(event.timeZone)) instant = localDateTimeToInstant(event.start, event.timeZone);
if (instant && !isNaN(instant.getTime())) {
let end: Date | null = event.utcEnd ? new Date(event.utcEnd) : null;
if ((!end || isNaN(end.getTime())) && durationMs > 0) end = new Date(instant.getTime() + durationMs);
return {
start: toDisplayDate(instant),
end: end && !isNaN(end.getTime()) && end.getTime() > instant.getTime() ? toDisplayDate(end) : null,
instant,
};
}
// Floating: the same wall clock everywhere.
const start = parseLocalDateTime(event.start);
if (isNaN(start.getTime())) return null;
return { start, end: durationMs > 0 ? new Date(start.getTime() + durationMs) : null };
}
/** The short name of the user's calendar zone at `instant` ("CET", "GMT+1"), or null. */
export function invitationZoneLabel(instant: Date, locale?: string): string | null {
try {
const part = new Intl.DateTimeFormat(locale, { timeZone: getEffectiveTimeZone(), timeZoneName: 'short' })
.formatToParts(instant)
.find((p) => p.type === 'timeZoneName');
return part?.value ?? null;
} catch {
return null;
}
}
+6 -2
View File
@@ -355,9 +355,12 @@ export function buildParticipantMap(
// stored as an inert JSPROP, so it is intentionally not sent.
const organizerKey = organizer.email.trim().toLowerCase();
const organizerStatus = known.get(organizerKey)?.status;
// A blank name is left out: written, it reaches the attendees' copies as
// an empty CN.
const organizerName = organizer.name?.trim();
participants[idFor(organizerKey)] = {
'@type': 'Participant',
name: organizer.name,
...(organizerName ? { name: organizerName } : {}),
email: organizer.email,
calendarAddress: `mailto:${organizer.email}`,
// owner only, NOT attendee: with roles.attendee set, Stalwart's server-side
@@ -380,9 +383,10 @@ export function buildParticipantMap(
if (!key || seen.has(key)) return;
seen.add(key);
const name = a.name?.trim();
participants[idFor(key)] = {
'@type': 'Participant',
name: a.name,
...(name ? { name } : {}),
email,
calendarAddress: `mailto:${email}`,
roles: { attendee: true },
+92
View File
@@ -0,0 +1,92 @@
import type { CalendarEvent } from '../api/types';
import { getEventEndDate, getEventStartDate } from './calendar-utils';
// Event search in the calendar (webmail components/calendar/calendar-search
// and hooks/use-calendar-event-search): the loaded events answer at once,
// the server's CalendarEvent/query `text` filter adds the rest.
/** The words of a search, lower case; every one has to match. */
export function searchTerms(query: string): string[] {
return query.toLowerCase().split(/\s+/).map((w) => w.trim()).filter(Boolean);
}
/** The CalendarEvent/query filter for the words: one `text` condition each. */
export function searchFilter(terms: string[]): Record<string, unknown> | null {
if (terms.length === 0) return null;
if (terms.length === 1) return { text: terms[0] };
return { operator: 'AND', conditions: terms.map((text) => ({ text })) };
}
/** What a search looks at: title, description, places and participants. */
function eventFields(event: CalendarEvent): string[] {
const fields = [event.title ?? '', event.description ?? ''];
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
for (const p of Object.values(event.participants ?? {})) {
if (p.name) fields.push(p.name);
if (p.email) fields.push(p.email);
if (p.calendarAddress) fields.push(p.calendarAddress.replace(/^mailto:/i, ''));
}
return fields.map((f) => f.toLowerCase());
}
/** Whether every word of the search is in one of the event's fields. */
export function eventMatches(event: CalendarEvent, terms: string[]): boolean {
if (terms.length === 0) return false;
const fields = eventFields(event);
return terms.every((term) => fields.some((field) => field.includes(term)));
}
export interface EventSearchResults {
/** Still to come or going on, soonest first. */
upcoming: CalendarEvent[];
/** Over, latest first. */
past: CalendarEvent[];
}
// One row per event: a series found as loaded occurrences and as the
// server's base event shows once. An occurrence renamed on its own keeps a
// row of its own.
const seriesKey = (event: CalendarEvent) =>
`${event.accountId ?? ''} ${event.uid || event.originalId || event.id} ${event.title ?? ''}`;
/**
* The rows of a search: of a series' loaded occurrences the next one to
* come, else the last; the server's events add what the loaded window
* lacks. Tasks and events without a start are left out.
*/
export function orderSearchResults(
local: CalendarEvent[],
remote: CalendarEvent[],
now: number,
): EventSearchResults {
const ends = (event: CalendarEvent) => getEventEndDate(event).getTime();
const starts = (event: CalendarEvent) => getEventStartDate(event).getTime();
const bySeries = new Map<string, CalendarEvent>();
for (const event of local) {
if (!event.start) continue;
const key = seriesKey(event);
const kept = bySeries.get(key);
if (!kept) {
bySeries.set(key, event);
continue;
}
const keptUpcoming = ends(kept) >= now;
const upcoming = ends(event) >= now;
if (upcoming && (!keptUpcoming || starts(event) < starts(kept))) bySeries.set(key, event);
else if (!upcoming && !keptUpcoming && starts(event) > starts(kept)) bySeries.set(key, event);
}
for (const event of remote) {
if (!event.start) continue;
const key = seriesKey(event);
if (!bySeries.has(key)) bySeries.set(key, event);
}
const upcoming: CalendarEvent[] = [];
const past: CalendarEvent[] = [];
for (const event of bySeries.values()) {
if (Number.isNaN(starts(event))) continue;
(ends(event) >= now ? upcoming : past).push(event);
}
upcoming.sort((a, b) => starts(a) - starts(b));
past.sort((a, b) => starts(b) - starts(a));
return { upcoming, past };
}
+5 -1
View File
@@ -113,7 +113,11 @@ export function addDaysToLocalDateTime(value: string, days: number): string {
/** End of an event as a display date - see getEventStartDate. */
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
// A timed event without a duration lasts zero seconds (RFC 8984's default
// PT0S). Stalwart stores a DTEND equal to DTSTART as no duration and gives
// the event a utcEnd of 23:59:59 on its start day, so it filled the rest
// of the day in the grid (#837).
if (!event.showWithoutTime && event.utcEnd && event.duration) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
+18 -5
View File
@@ -524,6 +524,16 @@ function generateCandidatesForPeriod(
candidates = expandWeekly(periodStart, rule, eventStart);
break;
case 'daily':
// The start's wall-clock time on this day. The period anchor is
// advanced day by day and keeps whatever time a DST gap pushed it to
// (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using
// it made every later day fail the implicit byHour and the series
// stopped at the gap. Mirrors the webmail (34fb9d247).
candidates = [new Date(
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
)];
break;
case 'hourly':
case 'minutely':
case 'secondly':
@@ -532,7 +542,10 @@ function generateCandidatesForPeriod(
break;
}
candidates = candidates.filter((d) => matchesByX(d, rule));
// A daily candidate is judged by the time it was built for: on the day of
// a DST gap that time does not exist and the occurrence moves past the gap
// (RFC 5545 3.3.5), which is still this occurrence.
candidates = candidates.filter((d) => matchesByX(d, rule, freq === 'daily' ? eventStart : undefined));
candidates.sort((a, b) => a.getTime() - b.getTime());
return candidates;
}
@@ -660,7 +673,7 @@ function expandWeekly(
return dates.sort((a, b) => a.getTime() - b.getTime());
}
function matchesByX(date: Date, rule: RecurrenceRule): boolean {
function matchesByX(date: Date, rule: RecurrenceRule, timeOf: Date = date): boolean {
if (rule.byMonth?.length) {
const month = String(date.getMonth() + 1);
if (!rule.byMonth.some((m) => m.replace('L', '') === month)) return false;
@@ -706,9 +719,9 @@ function matchesByX(date: Date, rule: RecurrenceRule): boolean {
})
) return false;
}
if (rule.byHour?.length && !rule.byHour.includes(date.getHours())) return false;
if (rule.byMinute?.length && !rule.byMinute.includes(date.getMinutes())) return false;
if (rule.bySecond?.length && !rule.bySecond.includes(date.getSeconds())) return false;
if (rule.byHour?.length && !rule.byHour.includes(timeOf.getHours())) return false;
if (rule.byMinute?.length && !rule.byMinute.includes(timeOf.getMinutes())) return false;
if (rule.bySecond?.length && !rule.bySecond.includes(timeOf.getSeconds())) return false;
return true;
}
+29 -2
View File
@@ -23,6 +23,7 @@ import {
ListChecks,
Menu,
Rows2,
Search,
Square,
} from '../components/icons';
import {
@@ -60,6 +61,7 @@ import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet';
import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet';
import { CalendarSearchSheet } from '../components/calendar/CalendarSearchSheet';
import {
computeScrollWindow,
fixedScrollWindowState,
@@ -93,7 +95,7 @@ import { buildReplyTo } from '../lib/calendar-invitation';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit';
import { participantLimitMessage } from '../lib/calendar-participants';
import { maxParticipantsPerEvent } from '../api/calendar';
import { maxParticipantsPerEvent, type CalendarUpdates } from '../api/calendar';
import { useContactNameResolver } from '../lib/contact-name-resolver';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { moveCalendar } from '../lib/calendar-sort';
@@ -225,6 +227,7 @@ export default function CalendarScreen() {
}, [scrollWindow]);
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
const [searchVisible, setSearchVisible] = React.useState(false);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
const [modalVisible, setModalVisible] = React.useState(false);
@@ -880,7 +883,10 @@ export default function CalendarScreen() {
} else {
// Only what the user changed: an untouched colour another client
// wrote may be one Stalwart 1.0 refuses.
const changes = changedCalendarFields(calendarEditTarget.calendar, values);
const changes: CalendarUpdates = changedCalendarFields(calendarEditTarget.calendar, values);
// The default reminders come only when the user changed them.
if (values.defaultAlertsWithTime) changes.defaultAlertsWithTime = values.defaultAlertsWithTime;
if (values.defaultAlertsWithoutTime) changes.defaultAlertsWithoutTime = values.defaultAlertsWithoutTime;
if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes);
}
},
@@ -979,6 +985,18 @@ export default function CalendarScreen() {
</View>
</Pressable>
<View style={styles.headerActions}>
<Pressable
style={styles.headerBtn}
onPress={() => {
setMonthPanelOpen(false);
setSearchVisible(true);
}}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.title', 'Search events')}
>
<Search size={20} color={c.text} />
</Pressable>
{/* Without free scrolling the views don't swipe: keep arrows. */}
{!freeScroll && (
<>
@@ -1280,6 +1298,15 @@ export default function CalendarScreen() {
onClose={() => setCalendarEditTarget(null)}
/>
<CalendarSearchSheet
visible={searchVisible}
calendars={calendars}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onClose={() => setSearchVisible(false)}
/>
<CalendarShareSheet
calendar={shareTarget}
onShare={shareCalendar}
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Writes still under way when the account changes. Event and calendar ids
// repeat across accounts (two accounts can both have calendar "b" with event
// "c"), so a write that finishes after the switch must not send its next
// step, which would reach the next account, nor change the store, which
// shows that account by then.
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
queryEvents: vi.fn(async () => []),
getEvents: vi.fn(async () => []),
getEventIdentities: vi.fn(async () => []),
scanCalendarObjects: vi.fn(async () => []),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(async () => undefined),
deleteEvents: vi.fn(async () => undefined),
batchCreateEvents: vi.fn(async (events: unknown[]) => ({ created: events.length, refused: [] })),
rsvpEvent: vi.fn(async () => undefined),
createCalendar: vi.fn(),
updateCalendar: vi.fn(async () => undefined),
deleteCalendar: vi.fn(async () => undefined),
setDefaultCalendar: vi.fn(async () => undefined),
supportsSyntheticCalendarIds: vi.fn(async () => false),
queryExpandedEvents: vi.fn(async () => []),
searchEventIds: vi.fn(async () => []),
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
resetSyntheticIdSupport: vi.fn(),
resetTimeZoneChecks: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }));
import * as calendarApi from '../../api/calendar';
import type { CalendarEvent } from '../../api/types';
import { CalendarAccountChangedError, searchCalendarEvents, useCalendarStore } from '../calendar-store';
const mockUpdate = vi.mocked(calendarApi.updateEvent);
const mockDelete = vi.mocked(calendarApi.deleteEvents);
const mockCreate = vi.mocked(calendarApi.createEvent);
const mockGetEvents = vi.mocked(calendarApi.getEvents);
const mockQueryEvents = vi.mocked(calendarApi.queryEvents);
const mockQueryExpanded = vi.mocked(calendarApi.queryExpandedEvents);
const mockBatchCreate = vi.mocked(calendarApi.batchCreateEvents);
const mockUpdateCalendar = vi.mocked(calendarApi.updateCalendar);
const mockCreateCalendar = vi.mocked(calendarApi.createCalendar);
const RID = '2026-03-02T09:00:00';
const participants = {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
};
const event = (id: string, title: string): CalendarEvent => ({
id, uid: `${id}@example.com`, title, start: '2026-03-03T10:00:00', duration: 'PT1H', calendarIds: { b: true },
} as unknown as CalendarEvent);
// The next account: its calendar "b" holds an event "c" too.
const THEIRS = event('c', 'Their event');
/** What an account switch does to the store while a request is under way. */
function switchAccount(): void {
useCalendarStore.getState().reset();
useCalendarStore.setState({
calendars: [{ id: 'b', name: 'Their calendar', sortOrder: 0 }] as never,
events: [THEIRS],
tasks: [{ ...THEIRS, '@type': 'Task', progress: 'needs-action' } as CalendarEvent],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' },
});
}
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.getState().reset();
useCalendarStore.setState({
calendars: [{ id: 'b', name: 'Mine', sortOrder: 0 }, { id: 'e', name: 'Other', sortOrder: 1 }] as never,
events: [event('c', 'My event')],
tasks: [{ ...event('c', 'My task'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' },
});
});
describe('a write the account switch overtook', () => {
it('names the account it started in and leaves the next account\'s event alone (updateEvent)', async () => {
mockUpdate.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().updateEvent('c', { title: 'Renamed' });
expect(mockUpdate).toHaveBeenCalledWith('c', { title: 'Renamed' }, undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('leaves the next account\'s event of the same id in the store (deleteEvent)', async () => {
mockDelete.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().deleteEvent('c');
expect(mockDelete).toHaveBeenCalledWith(['c'], undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('neither re-reads nor shows the event it made (createEvent)', async () => {
mockCreate.mockImplementationOnce(async () => {
switchAccount();
return event('n', 'New');
});
await useCalendarStore.getState().createEvent({ title: 'New' }, 'b');
expect(mockCreate).toHaveBeenCalledWith({ title: 'New' }, 'b', undefined, 'acc-1');
expect(mockGetEvents).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('leaves the answer off the next account\'s event (rsvpEvent)', async () => {
useCalendarStore.setState({ events: [{ ...event('c', 'Invite'), participants } as unknown as CalendarEvent] });
const theirs = { ...THEIRS, participants };
vi.mocked(calendarApi.rsvpEvent).mockImplementationOnce(async () => {
switchAccount();
useCalendarStore.setState({ events: [theirs as unknown as CalendarEvent] });
});
await useCalendarStore.getState().rsvpEvent('c', 'p1', 'declined');
expect(calendarApi.rsvpEvent).toHaveBeenCalledWith('c', 'p1', 'declined', undefined, 'acc-1');
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('accepted');
});
it('sends no second step of a first change to one server occurrence', async () => {
useCalendarStore.setState({
events: [{
id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
} as unknown as CalendarEvent],
});
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
// The quiet override only: the change itself would go to the next account.
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][2]).toBe(false);
expect(mockQueryExpanded).not.toHaveBeenCalled();
});
it('sends no second step of a first change to one occurrence expanded on the device', async () => {
useCalendarStore.setState({
events: [{
id: `base:${RID}`, originalId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
} as unknown as CalendarEvent],
});
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().updateEvent(`base:${RID}`, { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][2]).toBe(false);
});
it('imports no further batch into the next account\'s calendar of the same id', async () => {
mockBatchCreate.mockImplementationOnce(async (events: unknown[]) => {
switchAccount();
return { created: events.length, refused: [] };
});
const events = Array.from({ length: 60 }, (_, i) => ({ uid: `u${i}`, title: `E${i}`, start: '2026-03-03T10:00:00' }));
await expect(useCalendarStore.getState().importEvents(events, 'b')).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockQueryEvents).toHaveBeenCalledWith([], '', '', 'acc-1');
expect(mockBatchCreate).toHaveBeenCalledTimes(1);
expect(mockBatchCreate.mock.calls[0][2]).toBe('acc-1');
});
it('links no further UID once the account changed (importEvents)', async () => {
vi.mocked(calendarApi.getEventIdentities).mockResolvedValueOnce([
{ id: 'x1', uid: 'u1', calendarIds: { e: true } },
{ id: 'x2', uid: 'u2', calendarIds: { e: true } },
] as never);
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().importEvents([{ uid: 'u1' }, { uid: 'u2' }], 'b'))
.rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockBatchCreate).not.toHaveBeenCalled();
});
it('renumbers none of the next account\'s calendars (reorderCalendars)', async () => {
useCalendarStore.setState({ calendars: [{ id: 'b', name: 'Mine', sortOrder: 5 }, { id: 'e', name: 'Other', sortOrder: 6 }] as never });
mockUpdateCalendar.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().reorderCalendars(['e', 'b'])).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdateCalendar).toHaveBeenCalledTimes(1);
expect(mockUpdateCalendar.mock.calls[0][2]).toBe('acc-1');
expect(useCalendarStore.getState().calendars).toEqual([{ id: 'b', name: 'Their calendar', sortOrder: 0 }]);
});
it('adds the calendar it made to no other account\'s list (createCalendar)', async () => {
mockCreateCalendar.mockImplementationOnce(async () => {
switchAccount();
return { id: 'n', name: 'New' } as never;
});
await useCalendarStore.getState().createCalendar('New');
expect(mockCreateCalendar).toHaveBeenCalledWith('New', undefined, undefined, 'acc-1');
expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']);
});
it('removes none of the next account\'s calendar or events (removeCalendar)', async () => {
vi.mocked(calendarApi.deleteCalendar).mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().removeCalendar('b');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('b', 'acc-1');
expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']);
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('changes none of the next account\'s tasks', async () => {
mockUpdate.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().updateTask('c', { title: 'Renamed' });
expect(useCalendarStore.getState().tasks[0].title).toBe('Their event');
mockDelete.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().deleteTask('c');
expect(useCalendarStore.getState().tasks).toHaveLength(1);
// A refused tick is put back only on the task it was made on.
useCalendarStore.setState({ tasks: [{ ...event('c', 'Mine'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent] });
mockUpdate.mockImplementationOnce(async () => {
switchAccount();
useCalendarStore.setState({ tasks: [{ ...THEIRS, '@type': 'Task', progress: 'completed' } as CalendarEvent] });
throw new Error('Network request failed');
});
await expect(useCalendarStore.getState().toggleTaskComplete('c')).rejects.toThrow('Network request failed');
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
});
});
describe('searchCalendarEvents', () => {
const mockSearch = vi.mocked(calendarApi.searchEventIds);
it('searches every account the store shows and edits a find in its own account', async () => {
useCalendarStore.setState({
calendars: [{ id: 'b', name: 'Mine' }, { id: 'acc-2:t', originalId: 't', accountId: 'acc-2', name: 'Team', isShared: true }] as never,
events: [],
});
mockSearch.mockImplementation(async (_filter, accountId) => (accountId === 'acc-2' ? ['x'] : ['c']));
mockGetEvents.mockImplementation(async (ids: string[], accountId?: string) => ids.map((id) => ({
...event(id, accountId ? 'Team review' : 'My review'),
calendarIds: (accountId ? { t: true } : { b: true }) as Record<string, boolean>,
})));
const found = await searchCalendarEvents(['review']);
expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, undefined);
expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, 'acc-2');
expect(found.map((e) => e.id).sort()).toEqual(['acc-2:x', 'c']);
// Outside the loaded window, yet a change reaches the owner's account.
await useCalendarStore.getState().updateEvent('acc-2:x', { title: 'Renamed' });
expect(mockUpdate).toHaveBeenCalledWith('x', { title: 'Renamed' }, undefined, 'acc-2');
});
it('hands nothing found before an account switch to the next account', async () => {
mockSearch.mockImplementationOnce(async () => {
switchAccount();
return ['c'];
});
await expect(searchCalendarEvents(['review'])).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockGetEvents).not.toHaveBeenCalled();
});
});
@@ -111,7 +111,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St
['s9', { title: 'Standup, room 2', participants }, true],
]);
// Looked up around the occurrence, expanded.
expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true });
expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], 'acc-1', { expanded: true });
});
it('sends the change to the same id when the override keeps it (recurrence-keyed ids, live)', async () => {
@@ -134,7 +134,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, undefined]);
expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, 'acc-1']);
});
it('sends the change once to an occurrence that has its override already', async () => {
@@ -248,7 +248,7 @@ describe('an occurrence\'s alerts on a series that uses the calendar\'s default
await useCalendarStore.getState().updateEvent(`base:${RID}`, edit);
expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, undefined]);
expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, 'acc-1']);
expect(mockUpdate.mock.calls[1][0]).toBe('base');
expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides');
});
@@ -60,8 +60,8 @@ describe('calendar order', () => {
await done;
expect(mockUpdateCalendar.mock.calls).toEqual([
['b', { sortOrder: 1 }, undefined],
['a', { sortOrder: 2 }, undefined],
['b', { sortOrder: 1 }, 'acc-1'],
['a', { sortOrder: 2 }, 'acc-1'],
]);
});
+120 -21
View File
@@ -38,12 +38,16 @@ vi.mock('../../api/jmap-client', () => ({
}));
import * as calendarApi from '../../api/calendar';
import { jmapClient } from '../../api/jmap-client';
import { JMAPMethodError } from '../../api/jmap-result';
import {
useCalendarStore,
selectVisibleCalendars,
selectVisibleEvents,
ImportRefusedError,
CalendarAccountChangedError,
loadEventsInRange,
prepareImportedEvent,
} from '../calendar-store';
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
@@ -213,6 +217,39 @@ describe('calendar-store', () => {
expect(state.loadedRange).toBeNull();
expect(state.error).toBeNull();
});
// The reminders and the widget read ranges with loadEventsInRange
// directly, without a store generation of their own.
it('hands no events read before the reset to a range read outside the store', async () => {
// The reset comes while the account's events are being read. (The
// test above leaves its answer unread: its load stops before asking.)
mockQueryEvents.mockReset();
mockQueryEvents.mockImplementationOnce(async () => {
useCalendarStore.getState().reset();
return ['e-old'];
});
mockGetEvents.mockResolvedValue([{ id: 'e-old', title: 'Old account event', start: '2026-03-20T15:00:00', calendarIds: { 'cal-1': true } }]);
await expect(loadEventsInRange([{ id: 'cal-1' }] as never, ['cal-1'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z'))
.rejects.toBeInstanceOf(CalendarAccountChangedError);
});
it('reads no further account after the reset', async () => {
mockQueryEvents.mockReset();
mockQueryEvents.mockImplementationOnce(async () => {
useCalendarStore.getState().reset();
return [];
});
const calendars = [
{ id: 'cal-1' },
{ id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2' },
];
const read = loadEventsInRange(calendars as never, ['cal-1', 'acc-2:cal-9'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z');
await expect(read).rejects.toBeInstanceOf(CalendarAccountChangedError);
// The shared account would be read under the next login.
expect(mockQueryEvents).toHaveBeenCalledTimes(1);
});
});
describe('fetchTasks', () => {
@@ -382,6 +419,58 @@ describe('calendar-store', () => {
expect(mockGetCalendars).toHaveBeenCalled();
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
it('marks an account without calendars as loaded', async () => {
mockGetCalendars.mockResolvedValue([]);
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(useCalendarStore.getState().loadedRange).toEqual({ after: '2026-03-01', before: '2026-03-31' });
});
it('leaves the range unloaded when the calendars fail to load, so the next call tries again', async () => {
mockGetCalendars.mockRejectedValueOnce(new Error('Network request failed'));
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(useCalendarStore.getState().loadedRange).toBeNull();
mockGetCalendars.mockResolvedValueOnce([{ id: 'cal-1', name: 'Personal' }]);
mockQueryEvents.mockResolvedValue([]);
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined);
});
it('leaves the range unloaded without a session (an account loading)', async () => {
const client = jmapClient as { isConnected: boolean };
client.isConnected = false;
try {
await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31');
await useCalendarStore.getState().extendRange('2026-03-01', '2026-03-31');
} finally {
client.isConnected = true;
}
expect(useCalendarStore.getState().loadedRange).toBeNull();
expect(mockGetCalendars).not.toHaveBeenCalled();
});
it.each(['ensureRange', 'extendRange'] as const)('marks nothing loaded for the next account when a reset overtakes the calendar load (%s)', async (action) => {
let resolveCalendars: (v: unknown) => void = () => {};
mockGetCalendars.mockImplementationOnce(
() => new Promise((resolve) => { resolveCalendars = resolve; }),
);
if (action === 'extendRange') {
// A cold start: the window was cached, the calendars not yet loaded.
useCalendarStore.setState({ loadedRange: { after: '2026-02-01', before: '2026-03-01' } });
}
const load = useCalendarStore.getState()[action]('2026-03-01', '2026-03-31');
useCalendarStore.getState().reset();
resolveCalendars([]);
await load;
expect(useCalendarStore.getState().loadedRange).toBeNull();
expect(useCalendarStore.getState().calendars).toEqual([]);
});
});
describe('handleStateChange', () => {
@@ -507,7 +596,7 @@ describe('calendar-store', () => {
'cal-1',
);
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], undefined);
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], 'acc-1');
expect(result.utcStart).toBe('2026-03-02T08:00:00Z');
const events = useCalendarStore.getState().events;
expect(events).toHaveLength(3);
@@ -541,7 +630,7 @@ describe('calendar-store', () => {
// The third arg is the iMIP scheduling flag — undefined here since the
// event has no participants to notify. The fourth is the owning
// account — undefined for primary-account events.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, 'acc-1');
});
});
@@ -558,7 +647,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().setDefaultCalendar('cal-2');
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined);
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', 'acc-1');
const calendars = useCalendarStore.getState().calendars;
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
@@ -602,7 +691,7 @@ describe('calendar-store', () => {
);
await useCalendarStore.getState().deleteEvent(master!.id);
expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, 'acc-1');
// The visible range was reloaded (empty here).
expect(mockQueryEvents).toHaveBeenCalled();
});
@@ -632,10 +721,10 @@ describe('calendar-store', () => {
// No overrides yet: a pointer into the missing map would fail, so the
// whole map is sent.
expect(mockUpdateEvent).toHaveBeenNthCalledWith(
1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, undefined,
1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, 'acc-1',
);
expect(mockUpdateEvent).toHaveBeenNthCalledWith(
2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, undefined,
2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, 'acc-1',
);
});
});
@@ -669,7 +758,7 @@ describe('calendar-store', () => {
);
// The UID in cal-1 is linked into cal-2; the one already in cal-2 is skipped.
expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, 'acc-1');
expect(count).toBe(2);
expect(refused).toEqual([]);
const created = batch.mock.calls[0][0][0];
@@ -854,7 +943,7 @@ describe('calendar-store', () => {
start: '2026-03-02T10:00:00',
locations: { l: { name: 'Room 1' } },
sequence: 2,
}, undefined, undefined);
}, undefined, 'acc-1');
expect(mockQueryExpanded).toHaveBeenCalled();
});
@@ -879,14 +968,14 @@ describe('calendar-store', () => {
sequence: 2,
},
},
}, undefined, undefined);
}, undefined, 'acc-1');
// Remembered: a delete goes straight to the exclusion.
await useCalendarStore.getState().deleteEvent('s2');
expect(mockDeleteEvents).not.toHaveBeenCalled();
expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', {
recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } },
}, undefined, undefined);
}, undefined, 'acc-1');
} finally {
useCalendarStore.getState().reset();
}
@@ -901,7 +990,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().deleteEvent('s1');
expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, 'acc-1');
expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s2']);
});
@@ -914,7 +1003,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().deleteEvent('s5');
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([]);
});
@@ -957,7 +1046,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted');
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined);
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, 'acc-1');
});
it('answers just one server occurrence through its synthetic id', async () => {
@@ -983,7 +1072,7 @@ describe('calendar-store', () => {
expect(mockUpdateEvent).toHaveBeenCalledWith('s1', {
sequence: 4,
participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } },
}, true, undefined);
}, true, 'acc-1');
expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined');
});
});
@@ -1014,7 +1103,7 @@ describe('calendar-store', () => {
participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } },
},
},
}, true, undefined);
}, true, 'acc-1');
});
});
@@ -1046,7 +1135,7 @@ describe('calendar-store', () => {
// Never the series itself: the change becomes this occurrence's override.
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', {
'recurrenceOverrides/a': { start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'New' },
}, undefined, undefined);
}, undefined, 'acc-1');
// Refetched: siblings now carry the server's state.
const events = useCalendarStore.getState().events;
expect(events.length).toBeGreaterThan(0);
@@ -1065,7 +1154,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().updateEvent('ev1', { calendarIds: { 'acc-2:cal-9': true } });
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, 'acc-1');
});
it('getMasterEvent fetches the master an occurrence points at', async () => {
@@ -1108,7 +1197,7 @@ describe('calendar-store', () => {
// No organizer on the event: repaired from the invitation's replyTo.
expect(mockRsvpEvent).toHaveBeenCalledWith(
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined,
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', 'acc-1',
);
});
@@ -1142,7 +1231,7 @@ describe('calendar-store', () => {
't1',
{ progress: 'completed', percentComplete: 100 },
undefined,
undefined,
'acc-1',
);
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
});
@@ -1176,7 +1265,7 @@ describe('calendar-store', () => {
't1',
{ progress: 'needs-action', percentComplete: 0 },
undefined,
undefined,
'acc-1',
);
});
@@ -1328,7 +1417,7 @@ describe('editing one occurrence of a series', () => {
participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } },
},
},
}, true, undefined);
}, true, 'acc-1');
});
it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => {
@@ -1357,3 +1446,13 @@ describe('editing one occurrence of a series', () => {
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
});
});
describe('prepareImportedEvent', () => {
// #837: CalendarEvent/parse drops a DTEND equal to DTSTART, and stored
// without a duration Stalwart ends the event at 23:59:59.
it('imports a timed event without a duration as lasting zero seconds', () => {
expect(prepareImportedEvent({ uid: 'z', start: '2026-03-02T09:00:00', timeZone: 'Europe/Berlin' }).duration).toBe('PT0S');
expect(prepareImportedEvent({ uid: 'h', start: '2026-03-02T09:00:00', duration: 'PT1H' }).duration).toBe('PT1H');
expect(prepareImportedEvent({ uid: 't', '@type': 'Task', start: '2026-03-02T09:00:00' })).not.toHaveProperty('duration');
});
});
@@ -7,21 +7,25 @@ vi.mock('../../api/calendar', () => ({
parseCalendarBlob: vi.fn(),
queryEvents: vi.fn(),
getEventIdentities: vi.fn(),
getEvents: vi.fn(async () => []),
deleteEvents: vi.fn(),
updateEvent: vi.fn(),
updateCalendar: vi.fn(),
}));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' },
const client = vi.hoisted(() => ({
accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org',
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
const calendarStore = vi.hoisted(() => ({
generation: 0,
importEvents: vi.fn(async (_events: unknown[], _calendarId: string) => ({ created: 1, updated: 0 })),
fetchCalendars: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
}));
vi.mock('../calendar-store', () => ({
useCalendarStore: { getState: () => calendarStore },
calendarStoreGeneration: () => calendarStore.generation,
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
@@ -87,7 +91,7 @@ describe('updateSubscription', () => {
await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' });
expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' });
expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }, 'acc-1');
expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays');
});
});
@@ -145,7 +149,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000');
expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1');
expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1');
expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null });
expect(only().lastSyncAt).not.toBeNull();
@@ -196,12 +200,12 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c');
expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c', 'acc-1');
expect(calendarApi.queryEvents).not.toHaveBeenCalled();
expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
// The feed goes into a new mirror, as for an unlinked subscription.
expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000');
expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000', undefined, 'acc-1');
expect(calendarStore.importEvents.mock.calls.map(([, calendarId]) => calendarId)).toEqual(['n1']);
expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null });
});
@@ -228,7 +232,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']);
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1');
});
it('destroys only a calendar that carries the subscription name on remove', async () => {
@@ -238,7 +242,7 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
await useCalendarSubscriptionsStore.getState().removeSubscription('mine');
expect(calendarApi.deleteCalendar).not.toHaveBeenCalled();
await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1');
});
it('renames only a calendar that carries the subscription name', async () => {
@@ -264,10 +268,10 @@ describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => {
vi.mocked(calendarApi.queryEvents).mockResolvedValue(['x1']);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'x1', uid: 'gone', calendarIds: { c: true } }] as never);
await useCalendarSubscriptionsStore.getState().syncSubscription('mine');
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']);
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1'], undefined, 'acc-1');
expect(calendarApi.createCalendar).not.toHaveBeenCalled();
await useCalendarSubscriptionsStore.getState().removeSubscription('mine');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c', 'acc-1');
});
it('keeps its name when the mirror\'s name cannot be read', async () => {
@@ -360,3 +364,87 @@ describe('subscriptions made before they recorded their account', () => {
expect(only()).toEqual({ ...legacy, accountId: 'acc-1' });
});
});
describe('refreshing a subscription', () => {
const ME = 'user@mail.example.org';
const sub = (): CalendarSubscription => ({
id: 'feed', name: 'Holidays', url: 'https://feeds.example/holidays.ics', calendarId: 'b',
accountId: 'acc-1', localAccountId: ME, lastSyncAt: 5, lastError: null,
});
const only = () => useCalendarSubscriptionsStore.getState().subscriptions[0];
const feed = [
{ uid: 'kept', title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' },
{ uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' },
{ uid: 'mine', title: 'Feed copy', start: '2027-01-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' },
{ uid: 'new', title: 'New upstream', start: '2027-01-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' },
];
beforeEach(() => {
vi.clearAllMocks();
client.accountId = 'acc-1';
calendarStore.generation = 0;
useCalendarSubscriptionsStore.setState({ syncing: {}, subscriptions: [sub()] });
globalThis.fetch = vi.fn(async () => ({
ok: true, status: 200, headers: { get: () => null }, text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n',
})) as unknown as typeof fetch;
vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays');
vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue(feed as never);
vi.mocked(calendarApi.queryEvents).mockResolvedValue(['c', 'd', 'e', 'f']);
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([
{ id: 'c', uid: 'kept', calendarIds: { b: true } },
{ id: 'd', uid: 'same', calendarIds: { b: true } },
// The user's own event with the feed's UID, also in their calendar.
{ id: 'e', uid: 'mine', calendarIds: { b: true, personal: true } },
{ id: 'f', uid: 'gone', calendarIds: { b: true } },
] as never);
vi.mocked(calendarApi.getEvents).mockResolvedValue([
{ id: 'c', uid: 'kept', title: 'Before', start: '2027-01-02T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { b: true } },
{ id: 'd', uid: 'same', title: 'Unchanged', start: '2027-01-03T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', status: 'confirmed', calendarIds: { b: true } },
] as never);
});
it('gives the events still in the feed what it changed and imports only the new ones', async () => {
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
expect(calendarApi.getEvents).toHaveBeenCalledWith(['c', 'd'], 'acc-1');
expect(calendarApi.updateEvent).toHaveBeenCalledTimes(1);
expect(calendarApi.updateEvent).toHaveBeenCalledWith('c', expect.objectContaining({
title: 'Moved upstream', start: '2027-01-02T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
}), false, 'acc-1');
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['f'], undefined, 'acc-1');
expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'new' })], 'b');
expect(only().lastError).toBeNull();
});
it('stops when the client serves another account, whose calendar and events have the same ids', async () => {
// Both accounts have calendar "b" with events "c" to "f".
vi.mocked(calendarApi.queryEvents).mockImplementationOnce(async () => {
client.accountId = 'acc-2';
calendarStore.generation += 1;
return ['c', 'd', 'e', 'f'];
});
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
expect(calendarApi.queryEvents).toHaveBeenCalledWith(['b'], '', '', 'acc-1');
expect(calendarApi.getEventIdentities).not.toHaveBeenCalled();
expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
expect(calendarStore.importEvents).not.toHaveBeenCalled();
// Not the subscription's failure: it syncs again in its own account.
expect(only()).toMatchObject({ lastSyncAt: 5, lastError: null });
});
it('stops between its writes when the account changes', async () => {
vi.mocked(calendarApi.deleteEvents).mockImplementationOnce(async () => {
calendarStore.generation += 1;
});
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
expect(calendarApi.deleteEvents).toHaveBeenCalledTimes(1);
expect(calendarApi.getEvents).not.toHaveBeenCalled();
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
expect(calendarStore.importEvents).not.toHaveBeenCalled();
});
});
+259 -74
View File
@@ -18,6 +18,7 @@ import {
queryEvents,
getEvents as fetchEvents,
getEventIdentities,
searchEventIds,
scanCalendarObjects,
isCalendarAccessDenied,
noteCalendarAccessError,
@@ -59,6 +60,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-comp
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
import { requestDeviceSync } from '../device-sync/app/request';
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
import { searchFilter } from '../lib/calendar-search';
import { CALENDAR_AUTHORITY } from '../device-sync/types';
// extendRange merges up to twice the requested range (at least half a year,
@@ -141,7 +143,12 @@ export function prepareImportedEvent(event: Partial<CalendarEvent>): Partial<Cal
title: src.title,
description: src.description,
start: src.start,
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
// A timed event without a duration lasts zero seconds. CalendarEvent/parse
// drops a DTEND equal to DTSTART, and stored without a duration Stalwart
// ends the event at 23:59:59 (#837). Tasks have no duration.
duration: src.showWithoutTime
? normalizeAllDayDuration(src.duration)
: src.duration || (src['@type'] !== 'Task' && src.start ? 'PT0S' : undefined),
timeZone: src.showWithoutTime ? null : src.timeZone,
showWithoutTime: src.showWithoutTime,
status: src.status,
@@ -299,8 +306,8 @@ export interface CalendarState {
reset: () => void;
}
// In-flight dedupe for the two whole-account loads (see fetchCalendars).
let calendarsInFlight: Promise<void> | null = null;
// In-flight dedupe for the two whole-account loads (see loadCalendarList).
let calendarsInFlight: Promise<boolean> | null = null;
let tasksInFlight: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out). A load still under
@@ -310,14 +317,94 @@ let tasksInFlight: Promise<void> | null = null;
// one still in flight.
let storeGeneration = 0;
/** A range load the store's reset overtook: what it read belongs to the account before. */
/** The store's reset count: work started under an earlier one is the previous account's. */
export function calendarStoreGeneration(): number {
return storeGeneration;
}
/**
* A load or write the store's reset overtook: what it read belongs to the
* account before, and what it had still to write would reach the next one.
*/
export class CalendarAccountChangedError extends Error {
constructor() {
super('The calendar was reset while its events were loading');
super(t('calendar.account_changed', 'The account changed while the calendar was loading or saving.'));
this.name = 'CalendarAccountChangedError';
}
}
/**
* The account a write is made for, taken when it starts: the store
* generation and the primary JMAP account the client serves. Calendar and
* event ids repeat across accounts, so a write still under way after an
* account switch must not send its next step, which would reach the next
* account, nor change the store, which shows that account by then.
*/
interface WriteScope {
generation: number;
primaryAccountId: string | null;
}
function connectedAccountId(): string | null {
return jmapClient.isConnected ? jmapClient.accountId : null;
}
function writeScope(): WriteScope {
return { generation: storeGeneration, primaryAccountId: connectedAccountId() };
}
function inScope(scope: WriteScope): boolean {
return scope.generation === storeGeneration && connectedAccountId() === scope.primaryAccountId;
}
function assertInScope(scope: WriteScope): void {
if (!inScope(scope)) throw new CalendarAccountChangedError();
}
/**
* The JMAP account a write goes to, named on every request: a shared
* calendar's owner, else the primary account the write started in.
*/
function writeAccount(scope: WriteScope, accountId: string | undefined): string | undefined {
return accountId ?? scope.primaryAccountId ?? undefined;
}
/**
* fetchCalendars: load the calendar list into the store. True when it did;
* false without a session, when the request failed, or when a reset overtook
* it - the range loads then leave their range unloaded, so the next one
* tries again.
*/
function loadCalendarList(): Promise<boolean> {
// CalendarScreen fires this on mount; on cold start that happens before
// restoreSession has connected jmapClient. Bail rather than surfacing
// a "Not authenticated" error - the refetch driven by the auth-store
// will run this again once the session is live.
if (!jmapClient.isConnected) return Promise.resolve(false);
// First-touch gate (#907): the screen mount, ensureRange and the
// auth-store all kick this off at once, and on a clustered Stalwart
// concurrent first Calendar/* requests can each lazily create a default
// calendar. Share one in-flight request instead.
if (calendarsInFlight) return calendarsInFlight;
const generation = storeGeneration;
const set = useCalendarStore.setState;
calendarsInFlight = (async () => {
try {
const calendars = sortCalendars((await fetchCalendars()) ?? []);
if (generation !== storeGeneration) return false;
set({ calendars });
return true;
} catch (err) {
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
return false;
} finally {
// After a reset the slot belongs to the next account's load.
if (generation === storeGeneration) calendarsInFlight = null;
}
})();
return calendarsInFlight;
}
function persistHidden(ids: string[]): void {
void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch(
(err) => console.warn('[calendar-store] persist hidden failed', err),
@@ -427,6 +514,41 @@ export async function loadEventsInRange(
// here.
const knownMasters = new Map<string, CalendarEvent>();
/**
* The events of every calendar account the store shows that the server
* finds for `terms` (CalendarEvent/query `text`), whatever their date: a
* series by its stored event. They become known like a fetched master, so
* an edit or a delete from the detail sheet reaches their own account under
* their raw id. Throws CalendarAccountChangedError when the store was reset
* meanwhile; a failing shared account is skipped.
*/
export async function searchCalendarEvents(terms: string[]): Promise<CalendarEvent[]> {
const filter = searchFilter(terms);
if (!filter || !jmapClient.isConnected) return [];
const generation = storeGeneration;
const calendars = useCalendarStore.getState().calendars;
const accounts = new Set<string | undefined>([undefined]);
for (const cal of calendars) if (cal.accountId) accounts.add(cal.accountId);
const found: CalendarEvent[] = [];
for (const accountId of accounts) {
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
const ids = await searchEventIds(filter, accountId);
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
const events = ids.length > 0 ? await fetchEvents(ids, accountId) : [];
found.push(...events
.filter((e) => !isTaskLikeObject(e) && !!e.start)
.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
if (!accountId || err instanceof CalendarAccountChangedError) throw err;
}
}
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
for (const event of found) knownMasters.set(event.id, event);
return found;
}
export interface MutationTarget {
storeEvent?: CalendarEvent;
/** Raw JMAP id the change is sent to. */
@@ -499,6 +621,7 @@ async function updateBaseEvent(
updates: Record<string, unknown>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
if (writesOverrides && overrideSeriesKeysRejected) {
@@ -510,6 +633,7 @@ async function updateBaseEvent(
} catch (err) {
if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err;
overrideSeriesKeysRejected = true;
assertInScope(scope);
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
}
}
@@ -528,6 +652,7 @@ async function updateOccurrence(
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
if (!syntheticIdRejected) {
try {
@@ -537,10 +662,11 @@ async function updateOccurrence(
if (!isSyntheticIdMutationUnsupported(err)) throw err;
syntheticIdRejected = true;
}
assertInScope(scope);
}
const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true }));
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId);
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId, scope);
}
/**
@@ -598,11 +724,14 @@ async function updateOccurrenceInTwoSteps(
patch: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId);
await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId, scope);
assertInScope(scope);
const current = await findServerOccurrence(instance, accountId).catch(() => null);
assertInScope(scope);
if (!current) throw new OccurrenceNotFoundAgainError();
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId);
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId, scope);
}
/**
@@ -615,8 +744,9 @@ async function updateOccurrenceInTwoSteps(
* never shown. 0.16 stores and shows it either way; its series keeps the
* same alerts.
*/
async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined): Promise<void> {
async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined, scope: WriteScope): Promise<void> {
const [base] = await fetchEvents([baseId], accountId);
assertInScope(scope);
if (!base?.useDefaultAlerts) return;
const shown = base.alerts ?? {};
await apiUpdateEvent(baseId, {
@@ -632,6 +762,7 @@ async function destroyOccurrence(
syntheticId: string,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
if (!syntheticIdRejected) {
try {
@@ -641,6 +772,7 @@ async function destroyOccurrence(
if (!isSyntheticIdMutationUnsupported(err)) throw err;
syntheticIdRejected = true;
}
assertInScope(scope);
}
const fallback = buildFallbackExcludePatch(instance);
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude');
@@ -656,11 +788,12 @@ async function updateBrowserOccurrence(
target: MutationTarget,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const occurrence = target.storeEvent!;
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
if (!patch) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId), scope);
}
/**
@@ -673,21 +806,25 @@ async function updateBrowserOccurrenceInTwoSteps(
target: MutationTarget,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const steps = buildOverrideInTwoSteps(target.storeEvent!, updates);
if (!steps) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(target.realId, steps.quiet, false, target.accountId);
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, target.accountId);
const accountId = writeAccount(scope, target.accountId);
await updateBaseEvent(target.realId, steps.quiet, false, accountId, scope);
assertInScope(scope);
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, accountId, scope);
}
/** Delete one occurrence the device expanded by excluding it on its base event. */
async function destroyBrowserOccurrence(
target: MutationTarget,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const patch = buildFallbackExcludePatch(target.storeEvent!);
if (!patch) throw new Error('Cannot resolve the occurrence to exclude');
await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId));
}
/**
@@ -732,29 +869,7 @@ export const useCalendarStore = create<CalendarState>()(
},
fetchCalendars: async () => {
// CalendarScreen fires this on mount; on cold start that happens before
// restoreSession has connected jmapClient. Bail rather than surfacing
// a "Not authenticated" error - the refetch driven by the auth-store
// will run this again once the session is live.
if (!jmapClient.isConnected) return;
// First-touch gate (#907): the screen mount, ensureRange and the
// auth-store all kick this off at once, and on a clustered Stalwart
// concurrent first Calendar/* requests can each lazily create a default
// calendar. Share one in-flight request instead.
if (calendarsInFlight) return calendarsInFlight;
const generation = storeGeneration;
calendarsInFlight = (async () => {
try {
const calendars = sortCalendars((await fetchCalendars()) ?? []);
if (generation === storeGeneration) set({ calendars });
} catch (err) {
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
} finally {
// After a reset the slot belongs to the next account's load.
if (generation === storeGeneration) calendarsInFlight = null;
}
})();
return calendarsInFlight;
await loadCalendarList();
},
fetchEvents: async (calendarIds, after, before) => {
@@ -827,11 +942,19 @@ export const useCalendarStore = create<CalendarState>()(
const { loadedRange } = get();
if (loadedRange && rangeCovers(loadedRange, after, before)) return;
// Taken before the calendars load: a reset meanwhile (an account switch)
// makes this range the previous account's.
const generation = storeGeneration;
if (!jmapClient.isConnected) return;
// Queries are windowed now, so load exactly the requested range instead
// of an ever-growing union (which re-downloaded everything each time).
if (get().calendars.length === 0) {
// Calendars haven't loaded yet - fetch them (deduped), then events.
await get().fetchCalendars();
// A list that didn't load (no session any more, a failed request)
// leaves the range unloaded: marked loaded, it stayed empty.
const listed = await loadCalendarList();
if (generation !== storeGeneration || !listed) return;
}
const calendarIds = get().calendars.map((c) => c.id);
if (calendarIds.length === 0) {
@@ -840,6 +963,7 @@ export const useCalendarStore = create<CalendarState>()(
}
const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0;
await get().fetchEvents(calendarIds, after, before);
if (generation !== storeGeneration) return;
if (needTasks) void get().fetchTasks();
},
@@ -856,17 +980,22 @@ export const useCalendarStore = create<CalendarState>()(
await get().ensureRange(after, before);
return;
}
// Taken before the calendars load, as in ensureRange.
const generation = storeGeneration;
if (!jmapClient.isConnected) return;
// Calendars haven't loaded yet (cold start with a cached window): fetch
// them (deduped) first, like ensureRange.
if (get().calendars.length === 0) await get().fetchCalendars();
if (get().calendars.length === 0) {
const listed = await loadCalendarList();
if (generation !== storeGeneration || !listed) return;
}
const calendars = get().calendars;
const calendarIds = calendars.map((c) => c.id);
if (calendarIds.length === 0) {
set({ loadedRange: plan.union });
// Only the window this plan grew from: a jump elsewhere replaced it.
if (sameRange(get().loadedRange, loaded)) set({ loadedRange: plan.union });
return;
}
const generation = storeGeneration;
set({ loading: true, error: null });
try {
const incoming: CalendarEvent[] = [];
@@ -931,6 +1060,7 @@ export const useCalendarStore = create<CalendarState>()(
// Shared calendars live in the owner's account — route the create there,
// and against the calendar's raw server id (calendarId is the namespaced
// store id for shared calendars).
const scope = writeScope();
const calendars = get().calendars;
const cal = calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
@@ -940,19 +1070,22 @@ export const useCalendarStore = create<CalendarState>()(
event,
cal?.originalId || calendarId,
schedule,
accountId,
writeAccount(scope, accountId),
);
// Made; the store shows another account by now.
if (!inScope(scope)) return created;
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
// normalised recurrence rule); re-read the event so it renders at the
// right instant, then expand a recurring series across the loaded range
// instead of showing a single instance until the next refresh.
let full = created;
try {
const fetched = (await fetchEvents([created.id], accountId)) ?? [];
const fetched = (await fetchEvents([created.id], writeAccount(scope, accountId))) ?? [];
if (fetched[0]) full = fetched[0];
} catch {
// Keep the echo; the next refresh reconciles.
}
if (!inScope(scope)) return full;
// Map the same way fetchEvents does so the optimistic insert doesn't
// collide with the user's own events and stays visible under the right
// calendar filter.
@@ -971,8 +1104,10 @@ export const useCalendarStore = create<CalendarState>()(
// A change to an occurrence stays on that occurrence: through its
// synthetic id, or as an override on the base event it was expanded
// from. Everything else goes to the stored event.
const scope = writeScope();
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, accountId } = target;
const account = writeAccount(scope, accountId);
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
// raw server ids the owning account knows.
const patch: Record<string, unknown> = { ...changes };
@@ -1009,15 +1144,16 @@ export const useCalendarStore = create<CalendarState>()(
: storeEvent.participants);
if (target.isOccurrence && storeEvent) {
if (stopsDefaultAlerts && storeEvent.baseEventId) {
await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId);
await stopSeriesDefaultAlerts(storeEvent.baseEventId, account, scope);
assertInScope(scope);
}
if (twoSteps) {
try {
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, account, scope);
} catch (err) {
// Its quiet override is on the server, and the series' occurrences
// may have other ids now: a retry must not go to the ones in view.
if (err instanceof OccurrenceNotFoundAgainError) await get().refresh().catch(() => undefined);
if (err instanceof OccurrenceNotFoundAgainError && inScope(scope)) await get().refresh().catch(() => undefined);
throw err;
}
} else {
@@ -1026,19 +1162,25 @@ export const useCalendarStore = create<CalendarState>()(
realId,
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
schedule,
accountId,
account,
scope,
);
}
} else if (target.isBrowserOccurrence) {
if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId);
if (stopsDefaultAlerts) {
await stopSeriesDefaultAlerts(realId, account, scope);
assertInScope(scope);
}
if (twoSteps) {
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule);
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule, scope);
} else {
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule, scope);
}
} else {
await updateBaseEvent(realId, patch, schedule, accountId);
await updateBaseEvent(realId, patch, schedule, account, scope);
}
// Saved; the store shows another account by now, whose events can carry the same ids.
if (!inScope(scope)) return;
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
});
@@ -1056,19 +1198,24 @@ export const useCalendarStore = create<CalendarState>()(
deleteEvent: async (id) => {
// Deleting an occurrence removes just that one: through its synthetic
// id, or by excluding it on the base event it was expanded from.
const scope = writeScope();
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, accountId } = target;
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
if (target.isOccurrence && storeEvent) {
await destroyOccurrence(storeEvent, realId, schedule, accountId);
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
if (!inScope(scope)) return;
set({ events: get().events.filter((e) => e.id !== id) });
} else if (target.isBrowserOccurrence) {
await destroyBrowserOccurrence(target, schedule);
await destroyBrowserOccurrence(target, schedule, scope);
if (!inScope(scope)) return;
set({ events: get().events.filter((e) => e.id !== id) });
} else {
await apiDeleteEvents([realId], schedule, accountId);
await apiDeleteEvents([realId], schedule, writeAccount(scope, accountId));
// Deleted; the store shows another account by now, whose events can carry the same ids.
if (!inScope(scope)) return;
knownMasters.delete(id);
// Destroying a master removes every expanded occurrence of it, not
// just the tapped one.
@@ -1094,7 +1241,10 @@ export const useCalendarStore = create<CalendarState>()(
// and its SEQUENCE - a change that raises it from a stale one goes out
// lower than the attendees' copies, which 0.16 refuses.
const realId = seriesIdOf(event);
const generation = storeGeneration;
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
// Read from the account before a reset: not the next account's master.
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
const master = fetched[0];
if (!master) return null;
// Known from here on, so a change to it is sent to its own account under
@@ -1114,10 +1264,12 @@ export const useCalendarStore = create<CalendarState>()(
// isn't in the store; the caller hands it over instead. With scope
// 'series' an occurrence answers for its whole series: the stored event
// is updated.
const write = writeScope();
const target = resolveMutationTarget(get().events, eventId, scope);
const storeEvent = target.storeEvent ?? event;
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
const account = writeAccount(write, accountId);
const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence)
? target.storeEvent ?? null
: null;
@@ -1131,9 +1283,9 @@ export const useCalendarStore = create<CalendarState>()(
const patch = patchFor({ seriesKeys: true });
if (!patch) throw new Error('Participant not found on this occurrence');
if (target.isOccurrence) {
await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, accountId);
await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, account, write);
} else {
await updateBrowserOccurrence(target, patch, true);
await updateBrowserOccurrence(target, patch, true, write);
}
} else {
// Repair events that are missing the organizer (e.g. imported ones) so
@@ -1142,8 +1294,10 @@ export const useCalendarStore = create<CalendarState>()(
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
? replyTo.imip
: undefined;
await apiRsvpEvent(realId, participantId, status, repair, accountId);
await apiRsvpEvent(realId, participantId, status, repair, account);
}
// Answered; the store shows another account by now, whose events can carry the same ids.
if (!inScope(write)) return;
requestDeviceSync(CALENDAR_AUTHORITY);
set({
events: get().events.map((e) => {
@@ -1163,11 +1317,12 @@ export const useCalendarStore = create<CalendarState>()(
importEvents: async (events, calendarId) => {
if (events.length === 0) return { imported: 0, refused: [] };
const scope = writeScope();
// Shared calendars live in the owner's account and carry a namespaced
// store id — resolve the raw server id + owning account so dedup and
// create target the right place.
const cal = get().calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const accountId = writeAccount(scope, cal?.accountId);
const serverCalendarId = cal?.originalId || calendarId;
const refused: RefusedImport[] = [];
// Stalwart enforces UID uniqueness across calendars (#113):
@@ -1178,7 +1333,9 @@ export const useCalendarStore = create<CalendarState>()(
let linked = 0;
try {
const existingIds = await queryEvents([], '', '', accountId);
assertInScope(scope);
const existing = await getEventIdentities(existingIds, accountId);
assertInScope(scope);
const byUid = new Map<string, EventIdentityFields>();
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
const fresh: Partial<CalendarEvent>[] = [];
@@ -1189,6 +1346,7 @@ export const useCalendarStore = create<CalendarState>()(
continue;
}
if (found.calendarIds?.[serverCalendarId]) continue;
assertInScope(scope);
try {
await apiUpdateEvent(
found.id,
@@ -1203,7 +1361,8 @@ export const useCalendarStore = create<CalendarState>()(
}
}
toCreate = fresh;
} catch {
} catch (err) {
if (err instanceof CalendarAccountChangedError) throw err;
// Couldn't dedupe — proceed and let the server reject genuine dupes.
}
const prepared = toCreate.map(prepareImportedEvent);
@@ -1211,6 +1370,8 @@ export const useCalendarStore = create<CalendarState>()(
try {
// Batch in chunks of 50 to avoid oversized requests.
for (let i = 0; i < prepared.length; i += 50) {
// The rest would go to the next account's calendar of the same id.
assertInScope(scope);
try {
const result = await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId);
count += result.created;
@@ -1226,7 +1387,7 @@ export const useCalendarStore = create<CalendarState>()(
}
} finally {
// Also when a chunk was refused: show what did get in.
if (count > 0 || linked > 0) {
if ((count > 0 || linked > 0) && inScope(scope)) {
requestDeviceSync(CALENDAR_AUTHORITY);
await get().refresh();
}
@@ -1236,15 +1397,20 @@ export const useCalendarStore = create<CalendarState>()(
},
createCalendar: async (name, color, description) => {
const created = await apiCreateCalendar(name, color, description);
const scope = writeScope();
const created = await apiCreateCalendar(name, color, description, writeAccount(scope, undefined));
// Made in the account before a switch: not one of the next account's.
if (!inScope(scope)) return created;
set({ calendars: sortCalendars([...get().calendars, created]) });
requestDeviceSync(CALENDAR_AUTHORITY);
return created;
},
updateCalendar: async (id, updates) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
await apiUpdateCalendar(cal?.originalId || id, updates, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
});
@@ -1254,6 +1420,7 @@ export const useCalendarStore = create<CalendarState>()(
reorderCalendars: async (orderedIds) => {
const updates = sortOrderUpdates(get().calendars, orderedIds);
if (updates.length === 0) return;
const scope = writeScope();
const before = get().calendars;
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
@@ -1262,23 +1429,28 @@ export const useCalendarStore = create<CalendarState>()(
const saved = new Map<string, number>();
try {
for (const { id, sortOrder } of updates) {
// The rest would renumber the next account's calendars of the same ids.
assertInScope(scope);
const cal = before.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, writeAccount(scope, cal?.accountId));
saved.set(id, sortOrder);
}
} catch (err) {
// Show the order the server now holds: the moves saved before the
// failure stay, the rest are undone.
set({ calendars: withSortOrders(saved) });
// failure stay, the rest are undone. Not over the next account's list.
if (inScope(scope)) set({ calendars: withSortOrders(saved) });
throw err;
} finally {
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
if (saved.size > 0 && inScope(scope)) requestDeviceSync(CALENDAR_AUTHORITY);
}
},
removeCalendar: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);
await apiDeleteCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId));
// Removed; the next account may have a calendar of the same id.
if (!inScope(scope)) return;
set({
calendars: get().calendars.filter((c) => c.id !== id),
events: get().events.filter((e) => !e.calendarIds?.[id]),
@@ -1289,9 +1461,10 @@ export const useCalendarStore = create<CalendarState>()(
},
clearCalendarEvents: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
try {
const cleared = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId);
const cleared = await apiClearCalendarEvents(cal?.originalId || id, writeAccount(scope, cal?.accountId));
requestDeviceSync(CALENDAR_AUTHORITY);
return cleared;
} finally {
@@ -1301,8 +1474,10 @@ export const useCalendarStore = create<CalendarState>()(
},
shareCalendar: async (id, principalId, rights) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, cal?.accountId);
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: get().calendars.map((c) => {
if (c.id !== id) return c;
@@ -1315,8 +1490,10 @@ export const useCalendarStore = create<CalendarState>()(
},
setDefaultCalendar: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
await apiSetDefaultCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: get().calendars.map((c) => {
if (c.id === id) return { ...c, isDefault: true };
@@ -1331,20 +1508,24 @@ export const useCalendarStore = create<CalendarState>()(
},
createTask: async (task, calendarId) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === calendarId);
await apiCreateEvent(
{ ...task, '@type': 'Task' },
cal?.originalId || calendarId,
undefined,
cal?.accountId,
writeAccount(scope, cal?.accountId),
);
await get().fetchTasks();
},
updateTask: async (id, changes) => {
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes, undefined, task?.accountId);
await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId));
// Saved; the next account's tasks can carry the same ids.
if (!inScope(scope)) return;
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
},
@@ -1362,19 +1543,23 @@ export const useCalendarStore = create<CalendarState>()(
// Flip the checkbox right away and put it back if the server refuses.
const patchTask = (changes: Partial<CalendarEvent>) =>
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
const scope = writeScope();
patchTask(next);
try {
await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId);
await apiUpdateEvent(task.originalId || id, next, undefined, writeAccount(scope, task.accountId));
} catch (err) {
patchTask({ progress: task.progress, percentComplete: task.percentComplete });
// Not on the next account's task of the same id.
if (inScope(scope)) patchTask({ progress: task.progress, percentComplete: task.percentComplete });
throw err;
}
},
deleteTask: async (id) => {
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiDeleteEvents([realId], undefined, task?.accountId);
await apiDeleteEvents([realId], undefined, writeAccount(scope, task?.accountId));
if (!inScope(scope)) return;
set({ tasks: get().tasks.filter((t) => t.id !== id) });
},
+156 -39
View File
@@ -9,6 +9,7 @@ import {
parseCalendarBlob,
queryEvents,
getEventIdentities,
getEvents,
deleteEvents,
updateEvent,
updateCalendar,
@@ -16,9 +17,10 @@ import {
import { jmapClient } from '../api/jmap-client';
import { uploadBytes } from '../api/blob';
import { generateAccountId } from '../lib/account-utils';
import { feedChanged, feedFields } from '../lib/calendar-feed-diff';
import { readServerPrimaryAccountId } from '../lib/server-fingerprint';
import { useAccountStore } from './account-store';
import { useCalendarStore } from './calendar-store';
import { calendarStoreGeneration, useCalendarStore } from './calendar-store';
import { t } from './locale-store';
export interface CalendarSubscription {
@@ -118,9 +120,9 @@ export function currentLocalAccountId(): string | null {
// A calendar under the recorded id with another name is not the mirror: the
// id may be reused by a replaced server, or the user renamed the calendar.
// False when the server can't be asked.
async function isMirrorCalendar(calendarId: string, name: string): Promise<boolean> {
async function isMirrorCalendar(calendarId: string, name: string, accountId: string): Promise<boolean> {
try {
return (await getCalendarName(calendarId)) === name;
return (await getCalendarName(calendarId, accountId)) === name;
} catch {
return false;
}
@@ -177,7 +179,39 @@ function base64(input: string): string {
return input;
}
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
/**
* The account a sync runs for, taken when it starts: the JMAP account the
* client serves and the calendar store's reset count. Calendar and event ids
* repeat across accounts (two accounts can both have calendar "b" with events
* "c" and "d"), so a sync still under way after an account switch must stop
* before its next request: it would drop the next account's events and
* import the feed into it.
*/
interface SyncScope {
accountId: string;
generation: number;
}
function syncScope(): SyncScope | null {
const accountId = currentAccountId();
return accountId ? { accountId, generation: calendarStoreGeneration() } : null;
}
/** The account changed since the sync started. */
class SubscriptionAccountChangedError extends Error {
constructor() {
super(t('calendar.subscription.account_changed', 'The account changed while the subscription was syncing. It syncs again in its own account.'));
this.name = 'SubscriptionAccountChangedError';
}
}
function assertScope(scope: SyncScope): void {
if (currentAccountId() !== scope.accountId || calendarStoreGeneration() !== scope.generation) {
throw new SubscriptionAccountChangedError();
}
}
async function fetchAndParseFeed(url: string, scope: SyncScope): Promise<Partial<CalendarEvent>[]> {
const { url: feedUrl, headers } = normalizeFeedUrl(url);
const res = await fetch(feedUrl, { headers });
if (!res.ok) {
@@ -191,41 +225,98 @@ async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]>
if (!/BEGIN:VCALENDAR/i.test(text)) {
throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed'));
}
assertScope(scope);
const bytes = new TextEncoder().encode(text);
const { blobId } = await uploadBytes(bytes, 'text/calendar');
const events = await parseCalendarBlob(blobId);
const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId);
assertScope(scope);
const events = await parseCalendarBlob(blobId, scope.accountId);
assertScope(scope);
return events.filter((e) => !!e.start);
}
// Reconcile the mirror calendar with the remote feed: drop events whose UID
// disappeared upstream (unlinking rather than deleting when the event also
// lives in another calendar), then import the current set (importEvents
// dedupes by UID, so unchanged events are left in place). Callers make sure
// `calendarId` is the mirror: nothing goes into, nor is dropped from, another
// calendar. One made for this sync has nothing to drop.
async function syncFeedIntoCalendar(calendarId: string, url: string, dropStale: boolean): Promise<void> {
const parsed = await fetchAndParseFeed(url);
/**
* Reconcile the mirror calendar with the remote feed. With `linkedBefore`
* (the mirror existed before this sync): drop events whose UID disappeared
* upstream (unlinking rather than deleting when the event also lives in
* another calendar), and give the events still in the feed what it changed
* (time, title, place, recurrence; see lib/calendar-feed-diff). Only those in
* the mirror alone: one also linked to another calendar is the user's own
* event with the same UID and stays. Then import the events the mirror lacks
* (importEvents links a UID another calendar holds). Callers make sure
* `calendarId` is the mirror: nothing goes into, nor is dropped from,
* another calendar. One made for this sync has nothing to drop or update.
* Every request names the account the sync started in, and the sync stops
* once the client serves another (SubscriptionAccountChangedError).
*/
async function syncFeedIntoCalendar(
calendarId: string,
url: string,
linkedBefore: boolean,
scope: SyncScope,
): Promise<void> {
const { accountId } = scope;
const parsed = await fetchAndParseFeed(url, scope);
let toImport = parsed;
let wrote = false;
if (dropStale) {
if (linkedBefore) {
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
try {
const ids = await queryEvents([calendarId], '', '');
const existing = await getEventIdentities(ids);
const ids = await queryEvents([calendarId], '', '', accountId);
assertScope(scope);
const existing = await getEventIdentities(ids, accountId);
assertScope(scope);
const inMirror = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
toImport = parsed.filter((e) => !e.uid || !inMirror.has(e.uid));
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
const toDelete: string[] = [];
for (const e of stale) {
const others = { ...(e.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDelete.push(e.id);
else await updateEvent(e.id, { calendarIds: others });
if (Object.keys(others).length === 0) {
toDelete.push(e.id);
} else {
await updateEvent(e.id, { calendarIds: others }, undefined, accountId);
wrote = true;
assertScope(scope);
}
}
if (toDelete.length > 0) await deleteEvents(toDelete);
} catch {
if (toDelete.length > 0) {
await deleteEvents(toDelete, undefined, accountId);
wrote = true;
assertScope(scope);
}
const kept = existing.filter((e) =>
e.uid && parsedUids.has(e.uid) && Object.keys(e.calendarIds || {}).length === 1);
if (kept.length > 0) {
const stored = await getEvents(kept.map((e) => e.id), accountId);
assertScope(scope);
const byUid = new Map(stored.filter((e) => e.uid).map((e) => [e.uid!, e]));
for (const incoming of parsed) {
const current = incoming.uid ? byUid.get(incoming.uid) : undefined;
if (!current || !feedChanged(incoming, current)) continue;
byUid.delete(incoming.uid!);
try {
await updateEvent(current.id, feedFields(incoming), false, accountId);
wrote = true;
} catch {
// The next refresh tries again; the rest of the feed goes on.
}
assertScope(scope);
}
}
} catch (err) {
if (err instanceof SubscriptionAccountChangedError) throw err;
// Non-fatal: if we can't enumerate existing events, still import new ones.
}
}
await useCalendarStore.getState().importEvents(parsed, calendarId);
assertScope(scope);
// importEvents shows what it brought in; deletions and changes alone need a reload.
if (toImport.length > 0) await useCalendarStore.getState().importEvents(toImport, calendarId);
else if (wrote) await useCalendarStore.getState().refresh();
}
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
@@ -235,32 +326,44 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
syncing: {},
addSubscription: async ({ name, url, color, refreshIntervalMinutes }) => {
const scope = syncScope();
const localAccountId = currentLocalAccountId();
// Create a dedicated local calendar to hold the feed's events.
const calendar = await createCalendar(name, color);
const calendar = await createCalendar(name, color, undefined, scope?.accountId);
const sub: CalendarSubscription = {
id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name,
url: url.trim(),
color,
calendarId: calendar.id,
accountId: currentAccountId() ?? undefined,
localAccountId: currentLocalAccountId() ?? undefined,
accountId: scope?.accountId,
localAccountId: localAccountId ?? undefined,
refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES,
lastSyncAt: null,
lastError: null,
};
// Make the new calendar visible immediately.
await useCalendarStore.getState().fetchCalendars();
try {
if (!scope) throw new SubscriptionAccountChangedError();
assertScope(scope);
// Make the new calendar visible immediately.
await useCalendarStore.getState().fetchCalendars();
set({ syncing: { ...get().syncing, [sub.id]: true } });
await syncFeedIntoCalendar(calendar.id, sub.url, false);
await syncFeedIntoCalendar(calendar.id, sub.url, false, scope);
set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] });
await useCalendarStore.getState().refresh();
return { ...sub, lastSyncAt: Date.now() };
} catch (err) {
if (err instanceof SubscriptionAccountChangedError) {
// The mirror is in the account the sync started in, which the
// client no longer serves: a request to remove it would reach
// the next account. Kept as that account's subscription, it
// fills its mirror on that account's next sync.
set({ subscriptions: [...get().subscriptions, sub] });
throw err;
}
// Roll back: a bad URL / 404 must not leave a phantom calendar behind.
try {
await deleteCalendar(calendar.id);
await deleteCalendar(calendar.id, scope?.accountId);
} catch {
// best-effort
}
@@ -289,13 +392,16 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
// changes: a sync recognises the mirror by the subscription's name,
// and would take the old-named calendar for another one.
if (sub.calendarId && (next.name !== sub.name || next.color !== sub.color)) {
if ((await getCalendarName(sub.calendarId)) === sub.name) {
// Without a session this throws, as the name can't be read.
const scope = syncScope();
if ((await getCalendarName(sub.calendarId, scope?.accountId)) === sub.name && scope) {
assertScope(scope);
// Only what changed: Stalwart 1.0 refuses a colour that is not
// #rgb, #rrggbb or a CSS name, which another client may have set.
await updateCalendar(sub.calendarId, {
...(next.name !== sub.name ? { name: next.name } : {}),
...(next.color !== sub.color ? { color: next.color } : {}),
});
}, scope.accountId);
void useCalendarStore.getState().fetchCalendars().catch(() => undefined);
}
}
@@ -308,9 +414,11 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
// Only a calendar recognisably the mirror is destroyed, with its
// events: under a replaced server the recorded id can name another.
if (sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name))) {
const scope = syncScope();
if (scope && sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name, scope.accountId))) {
try {
await deleteCalendar(sub.calendarId);
assertScope(scope);
await deleteCalendar(sub.calendarId, scope.accountId);
} catch {
// best-effort
}
@@ -328,12 +436,16 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
const localAccountId = currentLocalAccountId();
if (sub.localAccountId && sub.localAccountId !== localAccountId) return;
if (!sub.calendarId && !localAccountId) return;
const scope = syncScope();
if (!scope) return;
set({ syncing: { ...get().syncing, [id]: true } });
try {
const jmapAccountId = currentAccountId();
const jmapAccountId = scope.accountId;
let calendarId = sub.calendarId;
// Throws when the server can't be asked; the next sync tries again.
if (calendarId && (await getCalendarName(calendarId)) !== sub.name) {
const mirrorName = calendarId ? await getCalendarName(calendarId, jmapAccountId) : null;
assertScope(scope);
if (calendarId && mirrorName !== sub.name) {
// The recorded id names another calendar, or none: the server was
// replaced unseen, or the mirror was renamed or removed elsewhere.
// The feed goes into its own mirror only: a new one, made as for
@@ -343,23 +455,25 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
&& !(localAccountId && (await ownsLegacySubscription(sub, localAccountId, [jmapAccountId])))) {
return;
}
assertScope(scope);
calendarId = null;
} else if (calendarId && !sub.localAccountId && localAccountId) {
// Its mirror is in this account, so it is this account's alone
// from now on, not every account's with the same JMAP id.
const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId ?? undefined };
const owner = { localAccountId, accountId: sub.accountId ?? jmapAccountId };
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...owner } : s)) });
}
if (!calendarId) {
// As addSubscription does. Linked right away, so a feed that
// fails below doesn't get another calendar on the next sync.
calendarId = (await createCalendar(sub.name, sub.color)).id;
const linked = { calendarId, accountId: jmapAccountId ?? undefined, localAccountId: localAccountId ?? undefined };
calendarId = (await createCalendar(sub.name, sub.color, undefined, jmapAccountId)).id;
const linked = { calendarId, accountId: jmapAccountId, localAccountId: localAccountId ?? undefined };
set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) });
assertScope(scope);
await useCalendarStore.getState().fetchCalendars();
}
const linkedBefore = calendarId === sub.calendarId;
await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore);
await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope);
set({
subscriptions: get().subscriptions.map((s) =>
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
@@ -367,6 +481,9 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
});
if (!linkedBefore) await useCalendarStore.getState().refresh();
} catch (err) {
// Not this subscription's failure: the client serves another
// account now, and the sync runs again in its own.
if (err instanceof SubscriptionAccountChangedError) return;
const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription');
set({
subscriptions: get().subscriptions.map((s) =>