mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: keep time zones, default alerts and calendar renames working on 1.0
Four smaller calendar changes that Stalwart 1.0 needs. - Time zone: 1.0 fails every CalendarEvent get and query whose timeZone its database lacks, with invalidArguments "Invalid timeZone". It matches IANA names case-sensitively and knows no offsets, so a "+01:00" or "europe/berlin" that Intl accepts empties the calendar. 0.16 reads such a zone as UTC. The zone is now checked with a get of no events that rides along with the first calendar list; once refused, reads ask in UTC and log one warning, and a read refused before the check answered is repeated once in UTC. The comment that said Stalwart ignores bad values is corrected. - Default alerts: 1.0 fills a calendar's default alerts into the alerts of an event that uses them. The editor wrote them back with useDefaultAlerts false on every save, freezing today's defaults into the event. It now writes nothing while the reminders are unchanged; a change turns the defaults off and saves the alerts under ids of their own, since 1.0 drops alerts saved under a default alert's id. - Calendar colour: renaming sent the colour along, and 1.0 accepts only #rgb, #rrggbb or a CSS name, so a calendar another client coloured with an 8-digit hex could not be renamed. The edit sheet and the iCal subscription settings now send only the fields that changed. - Reply stubs: 0.16 stored an attendee's answer to one occurrence without a title, place or description, and 1.0 builds that occurrence from the override alone. Hydration now takes a missing title from the series, and the place and description when the override removes the title.
This commit is contained in:
@@ -106,6 +106,8 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar reads on Stalwart %s',
|
||||
const hydration = server.gets()[1];
|
||||
expect(hydration.args.ids).toEqual(['b']);
|
||||
expect(hydration.args.properties).not.toContain('utcStart');
|
||||
// The series' title, place and description fill reply stubs.
|
||||
expect(hydration.args.properties).toEqual(expect.arrayContaining(['title', 'locations', 'description']));
|
||||
});
|
||||
|
||||
it(`finds an invitation outside the loaded window by UID on Stalwart ${version}`, async () => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
getMaxCallsInRequest: () => 16,
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import {
|
||||
getCalendars,
|
||||
getEvents,
|
||||
queryEvents,
|
||||
queryExpandedEvents,
|
||||
resetSyntheticIdSupport,
|
||||
resetTimeZoneChecks,
|
||||
} from '../calendar';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
createStalwartCalendarMock,
|
||||
type StalwartCalendarMock,
|
||||
type StalwartVersion,
|
||||
} from './stalwart-calendar-mock';
|
||||
|
||||
// Stalwart 1.0 fails every CalendarEvent/get and /query whose `timeZone` its
|
||||
// time zone database lacks: invalidArguments "Invalid timeZone \"X\""
|
||||
// (jmap-proto/src/object/calendar_event.rs). It matches IANA names
|
||||
// case-sensitively and knows no offsets, so a zone the phone's Intl accepts
|
||||
// can be refused. 0.16 reads such a zone as UTC, so the calendar kept
|
||||
// working there; on 1.0 it would show nothing but an error.
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
// Intl accepts it; Stalwart 1.0 does not.
|
||||
const ODD_ZONE = 'europe/berlin';
|
||||
|
||||
const SINGLE = {
|
||||
id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off',
|
||||
start: '2026-09-01T09:00:00', duration: 'PT30M', calendarIds: { cal: true },
|
||||
};
|
||||
|
||||
let server: StalwartCalendarMock;
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function serve(version: StalwartVersion): void {
|
||||
server = createStalwartCalendarMock({ version, objects: { d: SINGLE }, unknownTimeZones: [ODD_ZONE] });
|
||||
mockRequest.mockImplementation(server.request);
|
||||
}
|
||||
|
||||
const zonesOf = (calls: { args: Record<string, unknown> }[]) => calls.map((call) => call.args.timeZone);
|
||||
const reads = () => server.calls.filter((call) => call.callId !== 'time-zone-probe'
|
||||
&& (call.method === 'CalendarEvent/get' || call.method === 'CalendarEvent/query'));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSyntheticIdSupport();
|
||||
resetTimeZoneChecks();
|
||||
useSettingsStore.setState({ calendarTimeZone: ODD_ZONE });
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'auto' });
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('a time zone Stalwart 1.0 does not know', () => {
|
||||
it('is checked with the first calendar list, and reads ask in UTC from then on, on Stalwart 1.0', async () => {
|
||||
serve('1.0');
|
||||
|
||||
await getCalendars();
|
||||
const [first] = mockRequest.mock.calls[0];
|
||||
expect(first).toContainEqual(['CalendarEvent/get', { accountId: 'acc-1', ids: [], properties: ['id'], timeZone: ODD_ZONE }, 'time-zone-probe']);
|
||||
|
||||
const ids = await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z');
|
||||
const events = await getEvents(ids);
|
||||
|
||||
expect(events.map((e) => e.id)).toEqual(['d']);
|
||||
expect(zonesOf(reads())).toEqual(['UTC', 'UTC', 'UTC']);
|
||||
// The bounds are UTC wall clocks now, the same instants.
|
||||
expect((server.queries()[0].args.filter as { conditions: Record<string, string>[] }).conditions[1])
|
||||
.toEqual({ after: '2026-09-01T00:00:00', before: '2026-09-02T00:00:00' });
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('repeats a read refused before any check, once, in UTC, and remembers it on Stalwart 1.0', async () => {
|
||||
serve('1.0');
|
||||
|
||||
expect(await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')).toEqual(['d']);
|
||||
expect(zonesOf(server.queries())).toEqual([ODD_ZONE, 'UTC']);
|
||||
|
||||
await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z');
|
||||
expect(zonesOf(server.queries())).toEqual([ODD_ZONE, 'UTC', 'UTC']);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('expands a range in UTC after a refusal on Stalwart 1.0', async () => {
|
||||
serve('1.0');
|
||||
|
||||
await queryExpandedEvents('2026-09-01T00:00:00Z', '2026-09-30T00:00:00Z');
|
||||
|
||||
const [refused, repeated] = server.queries();
|
||||
expect(refused.args.timeZone).toBe(ODD_ZONE);
|
||||
expect(repeated.args).toMatchObject({
|
||||
timeZone: 'UTC',
|
||||
filter: { after: '2026-09-01T00:00:00', before: '2026-09-30T00:00:00' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps asking in the zone on 0.16, which reads an unknown zone as UTC', async () => {
|
||||
serve('0.16');
|
||||
|
||||
await getCalendars();
|
||||
await getEvents(await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z'));
|
||||
|
||||
expect(zonesOf(reads())).toEqual([ODD_ZONE, ODD_ZONE, ODD_ZONE]);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a zone Stalwart 1.0 knows', async () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' });
|
||||
serve('1.0');
|
||||
|
||||
await getCalendars();
|
||||
await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z');
|
||||
|
||||
expect(zonesOf(reads())).toEqual(['Europe/Berlin']);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks the zone again after a calendar list that failed on Stalwart 1.0', async () => {
|
||||
serve('1.0');
|
||||
mockRequest.mockRejectedValueOnce(new Error('Network request failed'));
|
||||
|
||||
await expect(getCalendars()).rejects.toThrow('Network request failed');
|
||||
await getCalendars();
|
||||
|
||||
expect(mockRequest.mock.calls[1][0].some((call: [string, unknown, string]) => call[2] === 'time-zone-probe')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends no check for UTC', async () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'UTC' });
|
||||
serve('1.0');
|
||||
|
||||
await getCalendars();
|
||||
|
||||
expect(mockRequest.mock.calls[0][0].some((call: [string, unknown, string]) => call[2] === 'time-zone-probe')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
toLocalDateTime,
|
||||
supportsSyntheticCalendarIds,
|
||||
resetSyntheticIdSupport,
|
||||
resetTimeZoneChecks,
|
||||
queryExpandedEvents,
|
||||
hydrateExpandedOccurrences,
|
||||
} from '../calendar';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
describe('toLocalDateTime', () => {
|
||||
it('renders an instant as wall-clock in the given zone', () => {
|
||||
@@ -55,6 +57,10 @@ function getAnswer(list: unknown[]) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSyntheticIdSupport();
|
||||
resetTimeZoneChecks();
|
||||
// In UTC the first calendar list carries no time zone check
|
||||
// (calendar-time-zone.test.ts covers that).
|
||||
useSettingsStore.setState({ calendarTimeZone: 'UTC' });
|
||||
});
|
||||
|
||||
describe('calendar operations', () => {
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface StalwartCalendarMockOptions {
|
||||
calendars?: Record<string, unknown>[];
|
||||
/** Whether CalendarEvent/set takes synthetic ids (the probe answers notFound). Default true. */
|
||||
syntheticIds?: boolean;
|
||||
/** Zones missing from the server's time zone database: 1.0 refuses them, 0.16 reads them as UTC. */
|
||||
unknownTimeZones?: string[];
|
||||
/** Answer a call yourself; return undefined for the default answer. */
|
||||
answer?: (call: RecordedCall) => MethodAnswer | undefined;
|
||||
}
|
||||
@@ -98,6 +100,12 @@ export function createStalwartCalendarMock(options: StalwartCalendarMockOptions)
|
||||
|
||||
const defaultAnswer = (call: RecordedCall): MethodAnswer => {
|
||||
const { method, args } = call;
|
||||
// jmap-proto/src/object/calendar_event.rs, resolved_time_zone: checked
|
||||
// before anything else in CalendarEvent/get and /query.
|
||||
if (options.version === '1.0' && (method === 'CalendarEvent/get' || method === 'CalendarEvent/query')
|
||||
&& typeof args.timeZone === 'string' && options.unknownTimeZones?.includes(args.timeZone)) {
|
||||
return ['error', { type: 'invalidArguments', description: `Invalid timeZone ${JSON.stringify(args.timeZone)}` }];
|
||||
}
|
||||
if (method === 'Calendar/get') {
|
||||
return ['Calendar/get', { accountId: args.accountId, list: options.calendars ?? [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }];
|
||||
}
|
||||
|
||||
+148
-43
@@ -16,12 +16,13 @@ const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
|
||||
/**
|
||||
* IANA time zone of the user — their calendar time-zone setting when set
|
||||
* (#755), otherwise the device's — sent as the `timeZone` argument on
|
||||
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
|
||||
* filter values and computes utcStart/utcEnd for floating events in this zone,
|
||||
* defaulting to UTC when absent — which shifts range boundaries and
|
||||
* floating-event times for any user not in UTC. Stalwart ignores unparseable
|
||||
* values, so sending it is always safe.
|
||||
* (#755), otherwise the device's. Stalwart interprets LocalDateTime filter
|
||||
* values and computes utcStart/utcEnd for floating events in the `timeZone`
|
||||
* argument of CalendarEvent/query and CalendarEvent/get, defaulting to UTC
|
||||
* when absent — which shifts range boundaries and floating-event times for
|
||||
* any user not in UTC. A zone the server's time zone database lacks is read
|
||||
* as UTC by 0.16 and refused by 1.0, which fails the whole call;
|
||||
* calendarTimeZone decides what is actually sent.
|
||||
*/
|
||||
function getUserTimeZone(): string | undefined {
|
||||
try {
|
||||
@@ -31,6 +32,95 @@ function getUserTimeZone(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Time zone check ─────────────────────────────────────
|
||||
// Stalwart 1.0 answers a CalendarEvent/get or /query whose `timeZone` it
|
||||
// does not know with invalidArguments "Invalid timeZone \"X\"". Its database
|
||||
// holds the IANA names, matched case-sensitively, and no offsets, so a
|
||||
// "+01:00" or "europe/berlin" that Intl may accept is refused.
|
||||
|
||||
const TIME_ZONE_PROBE_CALL_ID = 'time-zone-probe';
|
||||
// Per server and zone: the check of the zone, settled or in flight.
|
||||
const timeZoneProbes = new Map<string, Promise<void>>();
|
||||
// Per server and zone: refused by the server.
|
||||
const refusedTimeZones = new Set<string>();
|
||||
|
||||
function timeZoneKey(timeZone: string): string {
|
||||
return `${jmapClient.currentSession?.apiUrl ?? ''}|${timeZone}`;
|
||||
}
|
||||
|
||||
function isTimeZoneRefusal(type: string | undefined, description: string | undefined): boolean {
|
||||
return type === 'invalidArguments' && /invalid timeZone/i.test(description ?? '');
|
||||
}
|
||||
|
||||
/** A get of no events, which the server only answers if it knows `timeZone`. */
|
||||
function timeZoneProbeCall(accountId: string, timeZone: string): [string, Record<string, unknown>, string] {
|
||||
return ['CalendarEvent/get', { accountId, ids: [], properties: ['id'], timeZone }, TIME_ZONE_PROBE_CALL_ID];
|
||||
}
|
||||
|
||||
function refuseTimeZone(key: string, timeZone: string): void {
|
||||
if (refusedTimeZones.has(key)) return;
|
||||
refusedTimeZones.add(key);
|
||||
console.warn(
|
||||
`[calendar] The server does not know the time zone "${timeZone}"; asking it for UTC times instead. `
|
||||
+ 'Events without a time zone of their own may show shifted.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the verdict on `timeZone` from a response that carried its probe. A
|
||||
* request that failed outright is not remembered, so the next calendar list
|
||||
* checks again.
|
||||
*/
|
||||
function rememberTimeZoneProbe(key: string, timeZone: string, pending: Promise<any>): void {
|
||||
const probe: Promise<void> = pending.then(
|
||||
(res) => {
|
||||
const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find(
|
||||
(r) => r[2] === TIME_ZONE_PROBE_CALL_ID,
|
||||
);
|
||||
if (entry?.[0] === 'error' && isTimeZoneRefusal(entry[1]?.type, entry[1]?.description)) {
|
||||
refuseTimeZone(key, timeZone);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (timeZoneProbes.get(key) === probe) timeZoneProbes.delete(key);
|
||||
},
|
||||
);
|
||||
timeZoneProbes.set(key, probe);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `timeZone` argument for CalendarEvent/get and /query: the user's zone,
|
||||
* or UTC once the server refused it. The first calendar list checks the zone
|
||||
* on the way (getCalendars); a read that starts without that answer sends
|
||||
* the user's zone and is repeated in UTC if refused (withCalendarTimeZone).
|
||||
*/
|
||||
async function calendarTimeZone(): Promise<string | undefined> {
|
||||
const timeZone = getUserTimeZone();
|
||||
if (!timeZone) return undefined;
|
||||
const key = timeZoneKey(timeZone);
|
||||
await timeZoneProbes.get(key);
|
||||
return refusedTimeZones.has(key) ? 'UTC' : timeZone;
|
||||
}
|
||||
|
||||
/** Run a calendar read in calendarTimeZone, and once more in UTC when the server refuses that zone. */
|
||||
async function withCalendarTimeZone<T>(read: (timeZone: string | undefined) => Promise<T>): Promise<T> {
|
||||
const timeZone = await calendarTimeZone();
|
||||
try {
|
||||
return await read(timeZone);
|
||||
} catch (error) {
|
||||
if (!timeZone || timeZone === 'UTC') throw error;
|
||||
if (!isTimeZoneRefusal(jmapErrorType(error), error instanceof Error ? error.message : undefined)) throw error;
|
||||
refuseTimeZone(timeZoneKey(timeZone), timeZone);
|
||||
return read('UTC');
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the time zone checks: a new session may reach an upgraded server. */
|
||||
export function resetTimeZoneChecks(): void {
|
||||
timeZoneProbes.clear();
|
||||
refusedTimeZones.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock
|
||||
* in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and
|
||||
@@ -333,8 +423,14 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey);
|
||||
const methodCalls: [string, Record<string, unknown>, string][] = [['Calendar/get', { accountId }, '0']];
|
||||
if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId));
|
||||
// The user's time zone is checked on the way too (calendarTimeZone).
|
||||
const timeZone = getUserTimeZone();
|
||||
const zoneKey = timeZone && timeZone !== 'UTC' ? timeZoneKey(timeZone) : null;
|
||||
const checksTimeZone = !!zoneKey && !timeZoneProbes.has(zoneKey);
|
||||
if (checksTimeZone) methodCalls.push(timeZoneProbeCall(accountId, timeZone!));
|
||||
const pending = jmapClient.request(methodCalls, USING);
|
||||
if (carriesProbe) rememberSyntheticIdProbe(probeKey!, pending);
|
||||
if (checksTimeZone) rememberTimeZoneProbe(zoneKey!, timeZone!, pending);
|
||||
const res = await pending;
|
||||
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
|
||||
|
||||
@@ -395,36 +491,37 @@ export async function queryEvents(
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||
const range: Record<string, string> = {};
|
||||
if (after) range.after = toLocalDateTime(after, timeZone);
|
||||
if (before) range.before = toLocalDateTime(before, timeZone);
|
||||
if (Object.keys(range).length > 0) conditions.push(range);
|
||||
const filter = conditions.length === 1
|
||||
? conditions[0]
|
||||
: conditions.length > 1
|
||||
? { operator: 'AND', conditions }
|
||||
: undefined;
|
||||
const ids: string[] = [];
|
||||
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
|
||||
const args: Record<string, unknown> = {
|
||||
accountId: account,
|
||||
limit: QUERY_PAGE_SIZE,
|
||||
position: ids.length,
|
||||
};
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (filter) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
ids.push(...batch);
|
||||
if (batch.length < QUERY_PAGE_SIZE) break;
|
||||
}
|
||||
return ids;
|
||||
return withCalendarTimeZone(async (timeZone) => {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
|
||||
const range: Record<string, string> = {};
|
||||
if (after) range.after = toLocalDateTime(after, timeZone);
|
||||
if (before) range.before = toLocalDateTime(before, timeZone);
|
||||
if (Object.keys(range).length > 0) conditions.push(range);
|
||||
const filter = conditions.length === 1
|
||||
? conditions[0]
|
||||
: conditions.length > 1
|
||||
? { operator: 'AND', conditions }
|
||||
: undefined;
|
||||
const ids: string[] = [];
|
||||
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
|
||||
const args: Record<string, unknown> = {
|
||||
accountId: account,
|
||||
limit: QUERY_PAGE_SIZE,
|
||||
position: ids.length,
|
||||
};
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (filter) args.filter = filter;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
ids.push(...batch);
|
||||
if (batch.length < QUERY_PAGE_SIZE) break;
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Expanded ranges ─────────────────────────────────────
|
||||
@@ -566,7 +663,16 @@ export async function queryExpandedEvents(
|
||||
accountId?: string,
|
||||
): Promise<string[] | null> {
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
return withCalendarTimeZone((timeZone) => queryExpandedEventsIn(account, after, before, timeZone));
|
||||
}
|
||||
|
||||
/** queryExpandedEvents with the bounds and the request in `timeZone`. */
|
||||
async function queryExpandedEventsIn(
|
||||
account: string,
|
||||
after: string,
|
||||
before: string,
|
||||
timeZone: string | undefined,
|
||||
): Promise<string[] | null> {
|
||||
const bounds = { after: toLocalDateTime(after, timeZone), before: toLocalDateTime(before, timeZone) };
|
||||
const baseArgs: Record<string, unknown> = { accountId: account, expandRecurrences: true };
|
||||
if (timeZone) baseArgs.timeZone = timeZone;
|
||||
@@ -670,9 +776,8 @@ export async function hydrateExpandedOccurrences(
|
||||
if (baseIds.length === 0) return events;
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const bases = new Map<string, Partial<CalendarEvent>>();
|
||||
const list = await fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, {
|
||||
timeZone: getUserTimeZone(),
|
||||
});
|
||||
const list = await withCalendarTimeZone((timeZone) =>
|
||||
fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone }));
|
||||
for (const base of list as Partial<CalendarEvent>[]) {
|
||||
if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base));
|
||||
}
|
||||
@@ -768,12 +873,12 @@ export async function getEvents(
|
||||
): Promise<CalendarEvent[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const list = await fetchEventsById(
|
||||
const list = await withCalendarTimeZone((timeZone) => fetchEventsById(
|
||||
account,
|
||||
ids,
|
||||
options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
|
||||
{ withOverrides: !options?.expanded, timeZone: getUserTimeZone() },
|
||||
);
|
||||
{ withOverrides: !options?.expanded, timeZone },
|
||||
));
|
||||
return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
reminderFieldsToSave,
|
||||
preservedAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
@@ -314,6 +314,11 @@ export function EventModal({
|
||||
? [customRule]
|
||||
: undefined
|
||||
: recurrenceFromOption(recurrence);
|
||||
// The calendars' default alerts: a change of the reminders turns them
|
||||
// off for the event, and its alerts must not take their ids.
|
||||
const defaultAlertIds = calendars
|
||||
.filter((cal) => cal.id === calendarId || !!event?.calendarIds?.[cal.id])
|
||||
.flatMap((cal) => [...Object.keys(cal.defaultAlertsWithTime ?? {}), ...Object.keys(cal.defaultAlertsWithoutTime ?? {})]);
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || (isEdit && event?.description ? '' : undefined),
|
||||
@@ -321,8 +326,9 @@ export function EventModal({
|
||||
// into the event's own zone and keeps untouched ones as stored.
|
||||
...eventTimeFieldsToSave({ allDay, start, end }, event),
|
||||
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
|
||||
alerts: clearedOr(remindersToAlerts(reminders, preserved), 'alerts'),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
// Untouched reminders are not sent: an event on its calendar's
|
||||
// default alerts keeps them.
|
||||
...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds),
|
||||
locations: clearedOr(buildLocations(location), 'locations'),
|
||||
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
||||
};
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
preservedAlerts,
|
||||
reminderFieldsToSave,
|
||||
withOwnAlertIds,
|
||||
} from '../calendar-alerts';
|
||||
import { translate, type MessageParams } from '../../i18n';
|
||||
import type { Alert } from '../../api/types';
|
||||
|
||||
describe('calendar-alerts', () => {
|
||||
describe('offsetToMinutesBefore', () => {
|
||||
@@ -88,3 +92,75 @@ describe('calendar-alerts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// While an event uses its calendar's default alerts, Stalwart 1.0 fills the
|
||||
// defaults into `alerts` under the default alerts' ids, and on save drops
|
||||
// every alert under one of those ids. The editor wrote the alerts back with
|
||||
// useDefaultAlerts: false on every save, freezing today's defaults into the
|
||||
// event.
|
||||
describe('reminderFieldsToSave', () => {
|
||||
const offsetAlert = (offset: string) => ({
|
||||
'@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' }, action: 'display',
|
||||
}) as Alert;
|
||||
// What 1.0 returns for an event on its calendar's defaults.
|
||||
const onDefaults = {
|
||||
useDefaultAlerts: true,
|
||||
alerts: { 'default-15': offsetAlert('-PT15M'), 'default-abs': { '@type': 'Alert', trigger: { '@type': 'AbsoluteTrigger', when: '2026-09-01T08:00:00Z' } } as Alert },
|
||||
};
|
||||
|
||||
it('writes nothing while the reminders are the ones the event had, so the defaults stay on', () => {
|
||||
const reminders = alertsToReminders(onDefaults.alerts);
|
||||
expect(reminderFieldsToSave(onDefaults, reminders, preservedAlerts(onDefaults.alerts))).toEqual({});
|
||||
// Order and repeats are no change.
|
||||
expect(reminderFieldsToSave(
|
||||
{ alerts: { a: offsetAlert('-PT15M'), b: offsetAlert('-PT1H') } },
|
||||
[{ minutesBefore: 60 }, { minutesBefore: 15 }, { minutesBefore: 15 }],
|
||||
{},
|
||||
)).toEqual({});
|
||||
});
|
||||
|
||||
it('turns the defaults off for a changed event and gives its alerts ids of their own on Stalwart 1.0', () => {
|
||||
const fields = reminderFieldsToSave(
|
||||
onDefaults,
|
||||
[{ minutesBefore: 15 }, { minutesBefore: 60 }],
|
||||
preservedAlerts(onDefaults.alerts),
|
||||
['reminder-1'],
|
||||
);
|
||||
|
||||
expect(fields.useDefaultAlerts).toBe(false);
|
||||
const ids = Object.keys(fields.alerts ?? {});
|
||||
expect(ids).toHaveLength(3);
|
||||
for (const id of ids) expect(['default-15', 'default-abs', 'reminder-1']).not.toContain(id);
|
||||
// The alert the editor cannot show stays, under its new id.
|
||||
expect(Object.values(fields.alerts ?? {})).toContainEqual(onDefaults.alerts['default-abs']);
|
||||
});
|
||||
|
||||
it('removes the alerts and the defaults when every reminder is removed', () => {
|
||||
expect(reminderFieldsToSave(onDefaults, [], {})).toEqual({ alerts: null, useDefaultAlerts: false });
|
||||
});
|
||||
|
||||
it('replaces the own alerts of an event without defaults, keeping what the editor cannot show', () => {
|
||||
const own = { alerts: { a: offsetAlert('-PT15M'), abs: onDefaults.alerts['default-abs'] } };
|
||||
const fields = reminderFieldsToSave(own, [{ minutesBefore: 30 }], preservedAlerts(own.alerts));
|
||||
|
||||
expect(fields).toEqual({
|
||||
alerts: { abs: own.alerts.abs, 'reminder-1': offsetAlert('-PT30M') },
|
||||
useDefaultAlerts: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a new event its reminders, and writes nothing without any', () => {
|
||||
expect(reminderFieldsToSave(null, [{ minutesBefore: 10 }], {})).toEqual({
|
||||
alerts: { 'reminder-1': offsetAlert('-PT10M') },
|
||||
useDefaultAlerts: false,
|
||||
});
|
||||
expect(reminderFieldsToSave(null, [], {})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withOwnAlertIds', () => {
|
||||
it('numbers the alerts past every id taken', () => {
|
||||
const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M' } } as Alert;
|
||||
expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
eventsOnDay,
|
||||
normalizeAllDayDuration,
|
||||
buildAllDayDuration,
|
||||
changedCalendarFields,
|
||||
getCalendarColor,
|
||||
getEventStartDate,
|
||||
getEventEndDate,
|
||||
@@ -278,3 +279,27 @@ describe('layoutOverlappingEvents', () => {
|
||||
expect(layoutOverlappingEvents(events, day)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart 1.0 accepts a calendar colour only as #rgb, #rrggbb or a CSS name
|
||||
// (groupware/src/calendar/color.rs). The edit sheet sent the colour with
|
||||
// every rename, so a calendar another client coloured with Apple's 8-digit
|
||||
// hex could not be renamed.
|
||||
describe('changedCalendarFields', () => {
|
||||
const apple = { id: 'c1', name: 'Home', color: '#FF2968FF', description: 'Family' };
|
||||
|
||||
it('sends only the new name of a calendar whose colour came from another client', () => {
|
||||
expect(changedCalendarFields(apple, { name: 'House', color: '#FF2968FF', description: 'Family' })).toEqual({ name: 'House' });
|
||||
});
|
||||
|
||||
it('sends a colour the user picked, and clears an emptied description', () => {
|
||||
expect(changedCalendarFields(apple, { name: 'Home', color: '#3b82f6', description: '' })).toEqual({
|
||||
color: '#3b82f6',
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('compares with the colour the editor showed for a calendar without one', () => {
|
||||
const plain = { id: 'c2', name: 'Work' };
|
||||
expect(changedCalendarFields(plain, { name: 'Work', color: getCalendarColor(plain).toUpperCase(), description: '' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -433,3 +433,50 @@ describe('overrides returned as PatchObjects', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A reply to one occurrence, as 0.16 stored it: an override with little more
|
||||
// than the attendee - no title, place or description. 1.0 builds that
|
||||
// occurrence from the override alone, and returns the override as a
|
||||
// PatchObject that removes what the series has and the stub lacks.
|
||||
describe('hydrateRecurrenceInstances with a reply stub', () => {
|
||||
const series: Partial<CalendarEvent> = {
|
||||
id: 'b', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||||
title: 'Team sync', description: 'Agenda in the doc',
|
||||
locations: { l1: { '@type': 'Location', name: 'Room 4' } } as any,
|
||||
};
|
||||
const stub = (): CalendarEvent => {
|
||||
const occurrence: Partial<CalendarEvent> = ev({
|
||||
id: 's1', baseEventId: 'b', recurrenceId: '2026-09-08T10:00:00', start: '2026-09-08T10:00:00', duration: 'PT1H',
|
||||
});
|
||||
delete occurrence.title;
|
||||
return occurrence as CalendarEvent;
|
||||
};
|
||||
const withOverride = (override: Record<string, unknown>) => new Map<string, Partial<CalendarEvent>>([['b', {
|
||||
...series, recurrenceOverrides: { '2026-09-08T10:00:00': override as Partial<CalendarEvent> },
|
||||
}]]);
|
||||
|
||||
it('takes the title, place and description from the series on Stalwart 1.0', () => {
|
||||
const [out] = hydrateRecurrenceInstances([stub()], withOverride({
|
||||
title: null, description: null, locations: null, 'participants/att/participationStatus': 'declined',
|
||||
}));
|
||||
expect(out.title).toBe('Team sync');
|
||||
expect(out.locations).toEqual(series.locations);
|
||||
expect(out.description).toBe('Agenda in the doc');
|
||||
});
|
||||
|
||||
it('takes only the title from the series on 0.16, whose override is the stub itself', () => {
|
||||
const [out] = hydrateRecurrenceInstances([stub()], withOverride({
|
||||
participants: { att: { '@type': 'Participant', participationStatus: 'declined' } },
|
||||
}));
|
||||
expect(out.title).toBe('Team sync');
|
||||
expect(out.locations).toBeUndefined();
|
||||
expect(out.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a title the override sets, even an empty one', () => {
|
||||
const renamed = { ...stub(), title: 'Team sync (moved)' };
|
||||
expect(hydrateRecurrenceInstances([renamed], withOverride({ title: 'Team sync (moved)' }))[0].title)
|
||||
.toBe('Team sync (moved)');
|
||||
expect(hydrateRecurrenceInstances([{ ...stub(), title: '' }], withOverride({ title: '' }))[0].title).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,64 @@ export function remindersToAlerts(
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* `alerts` under new ids (reminder-1, reminder-2, ...), none of them in
|
||||
* `taken`. While `useDefaultAlerts` is on, Stalwart 1.0 shows the calendar's
|
||||
* defaults in `alerts` under the default alerts' ids, and on save it drops
|
||||
* every alert whose id is one of those: alerts that stay on after the
|
||||
* defaults are turned off need ids of their own.
|
||||
*/
|
||||
export function withOwnAlertIds(alerts: Alert[], taken: Iterable<string>): Record<string, Alert> {
|
||||
const used = new Set(taken);
|
||||
const result: Record<string, Alert> = {};
|
||||
let n = 0;
|
||||
for (const alert of alerts) {
|
||||
let id: string;
|
||||
do id = `reminder-${++n}`; while (used.has(id));
|
||||
result[id] = alert;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function reminderMinutes(reminders: Reminder[]): number[] {
|
||||
return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* keeps using them. Stalwart 1.0 fills those defaults into `alerts`, and
|
||||
* writing them back with `useDefaultAlerts: false`, as every save did,
|
||||
* froze today's defaults into the event.
|
||||
*
|
||||
* Changed reminders replace the event's alerts and turn the defaults off
|
||||
* (a change would not stick while they are on). The alerts of an event that
|
||||
* used the defaults then get ids of their own (withOwnAlertIds), away from
|
||||
* the event's and the calendar's default ids (`reservedIds`). 0.16 stores
|
||||
* the alerts either way.
|
||||
*/
|
||||
export function reminderFieldsToSave(
|
||||
event: Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'> | null | undefined,
|
||||
reminders: Reminder[],
|
||||
preserved: Record<string, Alert>,
|
||||
reservedIds: Iterable<string> = [],
|
||||
): Partial<Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'>> {
|
||||
const before = reminderMinutes(alertsToReminders(event?.alerts));
|
||||
const after = reminderMinutes(reminders);
|
||||
if (before.length === after.length && before.every((minutes, i) => minutes === after[i])) return {};
|
||||
|
||||
const usesDefaults = !!event?.useDefaultAlerts;
|
||||
let alerts = remindersToAlerts(reminders, preserved);
|
||||
if (alerts && usesDefaults) {
|
||||
alerts = withOwnAlertIds(Object.values(alerts), [...Object.keys(event?.alerts ?? {}), ...reservedIds]);
|
||||
}
|
||||
const fields: Partial<Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'>> = {};
|
||||
if (alerts) fields.alerts = alerts;
|
||||
else if (event?.alerts && Object.keys(event.alerts).length > 0) fields.alerts = null;
|
||||
if (reminders.length > 0 || usesDefaults) fields.useDefaultAlerts = false;
|
||||
return fields;
|
||||
}
|
||||
|
||||
type Translate = (key: string, fallback?: string, params?: MessageParams) => string;
|
||||
// Without a translator: the English text, plurals resolved.
|
||||
const plain: Translate = (key, fallback, params) => formatMessage(fallback ?? key, params, 'en');
|
||||
|
||||
@@ -536,6 +536,24 @@ export function getCalendarColor(calendar: Pick<Calendar, 'id' | 'color'> | unde
|
||||
return CALENDAR_PALETTE[hashString(calendar.id) % CALENDAR_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields of a calendar edit that differ from the calendar, and only
|
||||
* those. Stalwart 1.0 accepts a colour only as #rgb, #rrggbb or a CSS name,
|
||||
* so re-sending one another client wrote - Apple Calendar's 8-digit hex -
|
||||
* failed every rename. The colour is compared with the one the editor
|
||||
* started from (getCalendarColor).
|
||||
*/
|
||||
export function changedCalendarFields(
|
||||
calendar: Pick<Calendar, 'id' | 'name' | 'color' | 'description'>,
|
||||
values: { name: string; color: string; description: string },
|
||||
): { name?: string; color?: string; description?: string | null } {
|
||||
const changes: { name?: string; color?: string; description?: string | null } = {};
|
||||
if (values.name !== calendar.name) changes.name = values.name;
|
||||
if (values.color.toLowerCase() !== getCalendarColor(calendar).toLowerCase()) changes.color = values.color;
|
||||
if (values.description !== (calendar.description ?? '')) changes.description = values.description || null;
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function getEventColor(
|
||||
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
|
||||
calendars: Calendar[],
|
||||
|
||||
@@ -43,6 +43,9 @@ export const RECURRENCE_BASE_PROPERTIES = [
|
||||
'showWithoutTime',
|
||||
'timeZone',
|
||||
'duration',
|
||||
'title',
|
||||
'locations',
|
||||
'description',
|
||||
] as const;
|
||||
|
||||
type EventIdentity = Pick<CalendarEvent, 'id'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
|
||||
@@ -300,6 +303,28 @@ export function parseDurationSeconds(duration: string | null | undefined): numbe
|
||||
+ Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an occurrence built from a reply stub. 0.16 stored an attendee's
|
||||
* answer to one occurrence as an override holding little more than that
|
||||
* attendee - no SUMMARY, LOCATION or DESCRIPTION - and Stalwart 1.0 builds
|
||||
* such an occurrence from the override alone, so it shows untitled. The
|
||||
* title comes from the series unless the override sets one itself; the
|
||||
* place and description only when the override removes the title, which is
|
||||
* what a stub looks like as 1.0 returns it (`title: null` in the PatchObject).
|
||||
*/
|
||||
function fillReplyStub(
|
||||
occurrence: CalendarEvent,
|
||||
base: Partial<CalendarEvent>,
|
||||
override: Partial<CalendarEvent> | undefined,
|
||||
): void {
|
||||
if (!occurrence.title && base.title && typeof override?.title !== 'string') {
|
||||
occurrence.title = base.title;
|
||||
}
|
||||
if (override?.title !== null) return;
|
||||
if (occurrence.locations == null && base.locations) occurrence.locations = base.locations;
|
||||
if (!occurrence.description && base.description) occurrence.description = base.description;
|
||||
}
|
||||
|
||||
function shiftIso(from: string | null | undefined, seconds: number): string | null {
|
||||
if (!from) return null;
|
||||
const ms = Date.parse(from);
|
||||
@@ -314,8 +339,9 @@ function shiftIso(from: string | null | undefined, seconds: number): string | nu
|
||||
* and - for an override that does not set its own duration - the inherited
|
||||
* duration (RFC 8984 §4.3.4: an override is a patch on the base event; the
|
||||
* server's own computed duration for such overrides has been seen to be
|
||||
* wrong). Occurrences whose base event is not in `bases` are returned
|
||||
* unchanged.
|
||||
* wrong). A title the occurrence lacks comes from the series as well (see
|
||||
* fillReplyStub). Occurrences whose base event is not in `bases` are
|
||||
* returned unchanged.
|
||||
*/
|
||||
export function hydrateRecurrenceInstances(
|
||||
instances: CalendarEvent[],
|
||||
@@ -340,6 +366,7 @@ export function hydrateRecurrenceInstances(
|
||||
const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides);
|
||||
if (overrideKey) hydrated.recurrenceId = overrideKey;
|
||||
const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined;
|
||||
fillReplyStub(hydrated, base, override);
|
||||
if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) {
|
||||
hydrated.duration = base.duration;
|
||||
const seconds = parseDurationSeconds(base.duration);
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
addDaysToLocalDateTime,
|
||||
applySharedCalendarColors,
|
||||
buildEventDayIndex,
|
||||
changedCalendarFields,
|
||||
dayKey,
|
||||
eventsOnDayFromIndex,
|
||||
getEventStartDate,
|
||||
@@ -858,11 +859,10 @@ export default function CalendarScreen() {
|
||||
if (calendarEditTarget.mode === 'create') {
|
||||
await createCalendar(values.name, values.color, values.description);
|
||||
} else {
|
||||
await updateCalendar(calendarEditTarget.calendar.id, {
|
||||
name: values.name,
|
||||
color: values.color,
|
||||
description: values.description || null,
|
||||
});
|
||||
// Only what the user changed: an untouched colour another client
|
||||
// wrote may be one Stalwart 1.0 refuses.
|
||||
const changes = changedCalendarFields(calendarEditTarget.calendar, values);
|
||||
if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes);
|
||||
}
|
||||
},
|
||||
[calendarEditTarget, createCalendar, updateCalendar],
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('../../api/calendar', () => ({
|
||||
queryExpandedEvents: vi.fn(),
|
||||
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
|
||||
resetSyntheticIdSupport: vi.fn(),
|
||||
resetTimeZoneChecks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../api/calendar', () => ({
|
||||
queryExpandedEvents: vi.fn(),
|
||||
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
|
||||
resetSyntheticIdSupport: vi.fn(),
|
||||
resetTimeZoneChecks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
|
||||
@@ -25,9 +25,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import * as calendarApi from '../../api/calendar';
|
||||
import {
|
||||
normalizeFeedUrl,
|
||||
selectAccountSubscriptions,
|
||||
useCalendarSubscriptionsStore,
|
||||
type CalendarSubscription,
|
||||
} from '../calendar-subscriptions-store';
|
||||
|
||||
@@ -57,3 +59,18 @@ describe('selectAccountSubscriptions', () => {
|
||||
expect(selectAccountSubscriptions(subs, 'acc-2').map((s) => s.id)).toEqual(['b', 'legacy']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSubscription', () => {
|
||||
it('renames the mirror calendar without re-sending a colour Stalwart 1.0 refuses', async () => {
|
||||
const update = calendarApi.updateCalendar as ReturnType<typeof vi.fn>;
|
||||
update.mockResolvedValue(undefined);
|
||||
useCalendarSubscriptionsStore.setState({
|
||||
subscriptions: [{ id: 's', name: 'Feed', url: 'https://example.com/f.ics', color: '#FF2968FF', calendarId: 'c9', lastSyncAt: null, lastError: null }],
|
||||
});
|
||||
|
||||
await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' });
|
||||
|
||||
expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' });
|
||||
expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
queryExpandedEvents,
|
||||
hydrateExpandedOccurrences,
|
||||
resetSyntheticIdSupport,
|
||||
resetTimeZoneChecks,
|
||||
} from '../api/calendar';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { expandRecurringEvents } from '../lib/recurrence-expansion';
|
||||
@@ -1187,6 +1188,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
// upgraded server.
|
||||
resetCalendarAccessDenied();
|
||||
resetSyntheticIdSupport();
|
||||
resetTimeZoneChecks();
|
||||
syntheticIdRejected = false;
|
||||
overrideSeriesKeysRejected = false;
|
||||
knownMasters.clear();
|
||||
|
||||
@@ -201,7 +201,12 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
};
|
||||
if (next.name !== sub.name || next.color !== sub.color) {
|
||||
try {
|
||||
await updateCalendar(sub.calendarId, { name: next.name, color: next.color });
|
||||
// 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 } : {}),
|
||||
});
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
} catch {
|
||||
// The mirror calendar keeps its old name/colour; the sub still updates.
|
||||
|
||||
Reference in New Issue
Block a user