mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
Merge branch 'fx/cal' into feat/stalwart-1.0
This commit is contained in:
@@ -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})",
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
},
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user