mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep a calendar write that outlasts an account switch out of the next account
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"not_starred": "Not starred"
|
"not_starred": "Not starred"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
"account_changed": "The account changed while the calendar was loading or saving.",
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
|
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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 () => []),
|
||||||
|
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, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
['s9', { title: 'Standup, room 2', participants }, true],
|
||||||
]);
|
]);
|
||||||
// Looked up around the occurrence, expanded.
|
// 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 () => {
|
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 });
|
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
|
||||||
|
|
||||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
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 () => {
|
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);
|
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][0]).toBe('base');
|
||||||
expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides');
|
expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ describe('calendar order', () => {
|
|||||||
await done;
|
await done;
|
||||||
|
|
||||||
expect(mockUpdateCalendar.mock.calls).toEqual([
|
expect(mockUpdateCalendar.mock.calls).toEqual([
|
||||||
['b', { sortOrder: 1 }, undefined],
|
['b', { sortOrder: 1 }, 'acc-1'],
|
||||||
['a', { sortOrder: 2 }, undefined],
|
['a', { sortOrder: 2 }, 'acc-1'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ describe('calendar-store', () => {
|
|||||||
'cal-1',
|
'cal-1',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], undefined);
|
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], 'acc-1');
|
||||||
expect(result.utcStart).toBe('2026-03-02T08:00:00Z');
|
expect(result.utcStart).toBe('2026-03-02T08:00:00Z');
|
||||||
const events = useCalendarStore.getState().events;
|
const events = useCalendarStore.getState().events;
|
||||||
expect(events).toHaveLength(3);
|
expect(events).toHaveLength(3);
|
||||||
@@ -594,7 +594,7 @@ describe('calendar-store', () => {
|
|||||||
// The third arg is the iMIP scheduling flag — undefined here since the
|
// The third arg is the iMIP scheduling flag — undefined here since the
|
||||||
// event has no participants to notify. The fourth is the owning
|
// event has no participants to notify. The fourth is the owning
|
||||||
// account — undefined for primary-account events.
|
// account — undefined for primary-account events.
|
||||||
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined);
|
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, 'acc-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
await useCalendarStore.getState().setDefaultCalendar('cal-2');
|
await useCalendarStore.getState().setDefaultCalendar('cal-2');
|
||||||
|
|
||||||
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined);
|
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', 'acc-1');
|
||||||
const calendars = useCalendarStore.getState().calendars;
|
const calendars = useCalendarStore.getState().calendars;
|
||||||
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
|
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
|
||||||
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
|
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
|
||||||
@@ -655,7 +655,7 @@ describe('calendar-store', () => {
|
|||||||
);
|
);
|
||||||
await useCalendarStore.getState().deleteEvent(master!.id);
|
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).
|
// The visible range was reloaded (empty here).
|
||||||
expect(mockQueryEvents).toHaveBeenCalled();
|
expect(mockQueryEvents).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -685,10 +685,10 @@ describe('calendar-store', () => {
|
|||||||
// No overrides yet: a pointer into the missing map would fail, so the
|
// No overrides yet: a pointer into the missing map would fail, so the
|
||||||
// whole map is sent.
|
// whole map is sent.
|
||||||
expect(mockUpdateEvent).toHaveBeenNthCalledWith(
|
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(
|
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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -722,7 +722,7 @@ describe('calendar-store', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The UID in cal-1 is linked into cal-2; the one already in cal-2 is skipped.
|
// 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(count).toBe(2);
|
||||||
expect(refused).toEqual([]);
|
expect(refused).toEqual([]);
|
||||||
const created = batch.mock.calls[0][0][0];
|
const created = batch.mock.calls[0][0][0];
|
||||||
@@ -907,7 +907,7 @@ describe('calendar-store', () => {
|
|||||||
start: '2026-03-02T10:00:00',
|
start: '2026-03-02T10:00:00',
|
||||||
locations: { l: { name: 'Room 1' } },
|
locations: { l: { name: 'Room 1' } },
|
||||||
sequence: 2,
|
sequence: 2,
|
||||||
}, undefined, undefined);
|
}, undefined, 'acc-1');
|
||||||
expect(mockQueryExpanded).toHaveBeenCalled();
|
expect(mockQueryExpanded).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -932,14 +932,14 @@ describe('calendar-store', () => {
|
|||||||
sequence: 2,
|
sequence: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, undefined, undefined);
|
}, undefined, 'acc-1');
|
||||||
|
|
||||||
// Remembered: a delete goes straight to the exclusion.
|
// Remembered: a delete goes straight to the exclusion.
|
||||||
await useCalendarStore.getState().deleteEvent('s2');
|
await useCalendarStore.getState().deleteEvent('s2');
|
||||||
expect(mockDeleteEvents).not.toHaveBeenCalled();
|
expect(mockDeleteEvents).not.toHaveBeenCalled();
|
||||||
expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', {
|
expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', {
|
||||||
recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } },
|
recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } },
|
||||||
}, undefined, undefined);
|
}, undefined, 'acc-1');
|
||||||
} finally {
|
} finally {
|
||||||
useCalendarStore.getState().reset();
|
useCalendarStore.getState().reset();
|
||||||
}
|
}
|
||||||
@@ -954,7 +954,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
await useCalendarStore.getState().deleteEvent('s1');
|
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']);
|
expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -967,7 +967,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
await useCalendarStore.getState().deleteEvent('s5');
|
await useCalendarStore.getState().deleteEvent('s5');
|
||||||
|
|
||||||
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined);
|
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, 'acc-1');
|
||||||
expect(useCalendarStore.getState().events).toEqual([]);
|
expect(useCalendarStore.getState().events).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1010,7 +1010,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted');
|
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 () => {
|
it('answers just one server occurrence through its synthetic id', async () => {
|
||||||
@@ -1036,7 +1036,7 @@ describe('calendar-store', () => {
|
|||||||
expect(mockUpdateEvent).toHaveBeenCalledWith('s1', {
|
expect(mockUpdateEvent).toHaveBeenCalledWith('s1', {
|
||||||
sequence: 4,
|
sequence: 4,
|
||||||
participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } },
|
participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } },
|
||||||
}, true, undefined);
|
}, true, 'acc-1');
|
||||||
expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined');
|
expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1067,7 +1067,7 @@ describe('calendar-store', () => {
|
|||||||
participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } },
|
participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, true, undefined);
|
}, true, 'acc-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1099,7 +1099,7 @@ describe('calendar-store', () => {
|
|||||||
// Never the series itself: the change becomes this occurrence's override.
|
// Never the series itself: the change becomes this occurrence's override.
|
||||||
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', {
|
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', {
|
||||||
'recurrenceOverrides/a': { start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'New' },
|
'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.
|
// Refetched: siblings now carry the server's state.
|
||||||
const events = useCalendarStore.getState().events;
|
const events = useCalendarStore.getState().events;
|
||||||
expect(events.length).toBeGreaterThan(0);
|
expect(events.length).toBeGreaterThan(0);
|
||||||
@@ -1118,7 +1118,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
await useCalendarStore.getState().updateEvent('ev1', { calendarIds: { 'acc-2:cal-9': true } });
|
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 () => {
|
it('getMasterEvent fetches the master an occurrence points at', async () => {
|
||||||
@@ -1161,7 +1161,7 @@ describe('calendar-store', () => {
|
|||||||
|
|
||||||
// No organizer on the event: repaired from the invitation's replyTo.
|
// No organizer on the event: repaired from the invitation's replyTo.
|
||||||
expect(mockRsvpEvent).toHaveBeenCalledWith(
|
expect(mockRsvpEvent).toHaveBeenCalledWith(
|
||||||
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined,
|
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', 'acc-1',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1195,7 +1195,7 @@ describe('calendar-store', () => {
|
|||||||
't1',
|
't1',
|
||||||
{ progress: 'completed', percentComplete: 100 },
|
{ progress: 'completed', percentComplete: 100 },
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
'acc-1',
|
||||||
);
|
);
|
||||||
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
||||||
});
|
});
|
||||||
@@ -1229,7 +1229,7 @@ describe('calendar-store', () => {
|
|||||||
't1',
|
't1',
|
||||||
{ progress: 'needs-action', percentComplete: 0 },
|
{ progress: 'needs-action', percentComplete: 0 },
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
'acc-1',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1381,7 +1381,7 @@ describe('editing one occurrence of a series', () => {
|
|||||||
participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } },
|
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 () => {
|
it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => {
|
||||||
|
|||||||
+154
-44
@@ -315,14 +315,53 @@ export function calendarStoreGeneration(): number {
|
|||||||
return storeGeneration;
|
return storeGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A range load the store's reset overtook: what it read belongs to the account before. */
|
/**
|
||||||
|
* 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 {
|
export class CalendarAccountChangedError extends Error {
|
||||||
constructor() {
|
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';
|
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;
|
* 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
|
* false without a session, when the request failed, or when a reset overtook
|
||||||
@@ -540,6 +579,7 @@ async function updateBaseEvent(
|
|||||||
updates: Record<string, unknown>,
|
updates: Record<string, unknown>,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
|
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
|
||||||
if (writesOverrides && overrideSeriesKeysRejected) {
|
if (writesOverrides && overrideSeriesKeysRejected) {
|
||||||
@@ -551,6 +591,7 @@ async function updateBaseEvent(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err;
|
if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err;
|
||||||
overrideSeriesKeysRejected = true;
|
overrideSeriesKeysRejected = true;
|
||||||
|
assertInScope(scope);
|
||||||
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
|
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,6 +610,7 @@ async function updateOccurrence(
|
|||||||
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
|
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!syntheticIdRejected) {
|
if (!syntheticIdRejected) {
|
||||||
try {
|
try {
|
||||||
@@ -578,10 +620,11 @@ async function updateOccurrence(
|
|||||||
if (!isSyntheticIdMutationUnsupported(err)) throw err;
|
if (!isSyntheticIdMutationUnsupported(err)) throw err;
|
||||||
syntheticIdRejected = true;
|
syntheticIdRejected = true;
|
||||||
}
|
}
|
||||||
|
assertInScope(scope);
|
||||||
}
|
}
|
||||||
const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true }));
|
const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true }));
|
||||||
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override');
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -639,11 +682,14 @@ async function updateOccurrenceInTwoSteps(
|
|||||||
patch: Partial<CalendarEvent>,
|
patch: Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): 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);
|
const current = await findServerOccurrence(instance, accountId).catch(() => null);
|
||||||
|
assertInScope(scope);
|
||||||
if (!current) throw new OccurrenceNotFoundAgainError();
|
if (!current) throw new OccurrenceNotFoundAgainError();
|
||||||
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId);
|
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -656,8 +702,9 @@ async function updateOccurrenceInTwoSteps(
|
|||||||
* never shown. 0.16 stores and shows it either way; its series keeps the
|
* never shown. 0.16 stores and shows it either way; its series keeps the
|
||||||
* same alerts.
|
* 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);
|
const [base] = await fetchEvents([baseId], accountId);
|
||||||
|
assertInScope(scope);
|
||||||
if (!base?.useDefaultAlerts) return;
|
if (!base?.useDefaultAlerts) return;
|
||||||
const shown = base.alerts ?? {};
|
const shown = base.alerts ?? {};
|
||||||
await apiUpdateEvent(baseId, {
|
await apiUpdateEvent(baseId, {
|
||||||
@@ -673,6 +720,7 @@ async function destroyOccurrence(
|
|||||||
syntheticId: string,
|
syntheticId: string,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!syntheticIdRejected) {
|
if (!syntheticIdRejected) {
|
||||||
try {
|
try {
|
||||||
@@ -682,6 +730,7 @@ async function destroyOccurrence(
|
|||||||
if (!isSyntheticIdMutationUnsupported(err)) throw err;
|
if (!isSyntheticIdMutationUnsupported(err)) throw err;
|
||||||
syntheticIdRejected = true;
|
syntheticIdRejected = true;
|
||||||
}
|
}
|
||||||
|
assertInScope(scope);
|
||||||
}
|
}
|
||||||
const fallback = buildFallbackExcludePatch(instance);
|
const fallback = buildFallbackExcludePatch(instance);
|
||||||
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude');
|
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude');
|
||||||
@@ -697,11 +746,12 @@ async function updateBrowserOccurrence(
|
|||||||
target: MutationTarget,
|
target: MutationTarget,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const occurrence = target.storeEvent!;
|
const occurrence = target.storeEvent!;
|
||||||
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
|
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
|
||||||
if (!patch) throw new Error('Cannot resolve the occurrence to override');
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -714,21 +764,25 @@ async function updateBrowserOccurrenceInTwoSteps(
|
|||||||
target: MutationTarget,
|
target: MutationTarget,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const steps = buildOverrideInTwoSteps(target.storeEvent!, updates);
|
const steps = buildOverrideInTwoSteps(target.storeEvent!, updates);
|
||||||
if (!steps) throw new Error('Cannot resolve the occurrence to override');
|
if (!steps) throw new Error('Cannot resolve the occurrence to override');
|
||||||
await updateBaseEvent(target.realId, steps.quiet, false, target.accountId);
|
const accountId = writeAccount(scope, target.accountId);
|
||||||
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, 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. */
|
/** Delete one occurrence the device expanded by excluding it on its base event. */
|
||||||
async function destroyBrowserOccurrence(
|
async function destroyBrowserOccurrence(
|
||||||
target: MutationTarget,
|
target: MutationTarget,
|
||||||
sendSchedulingMessages: boolean | undefined,
|
sendSchedulingMessages: boolean | undefined,
|
||||||
|
scope: WriteScope,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const patch = buildFallbackExcludePatch(target.storeEvent!);
|
const patch = buildFallbackExcludePatch(target.storeEvent!);
|
||||||
if (!patch) throw new Error('Cannot resolve the occurrence to exclude');
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -964,6 +1018,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
// Shared calendars live in the owner's account — route the create there,
|
// Shared calendars live in the owner's account — route the create there,
|
||||||
// and against the calendar's raw server id (calendarId is the namespaced
|
// and against the calendar's raw server id (calendarId is the namespaced
|
||||||
// store id for shared calendars).
|
// store id for shared calendars).
|
||||||
|
const scope = writeScope();
|
||||||
const calendars = get().calendars;
|
const calendars = get().calendars;
|
||||||
const cal = calendars.find((c) => c.id === calendarId);
|
const cal = calendars.find((c) => c.id === calendarId);
|
||||||
const accountId = cal?.accountId;
|
const accountId = cal?.accountId;
|
||||||
@@ -973,19 +1028,22 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
event,
|
event,
|
||||||
cal?.originalId || calendarId,
|
cal?.originalId || calendarId,
|
||||||
schedule,
|
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
|
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
|
||||||
// normalised recurrence rule); re-read the event so it renders at the
|
// normalised recurrence rule); re-read the event so it renders at the
|
||||||
// right instant, then expand a recurring series across the loaded range
|
// right instant, then expand a recurring series across the loaded range
|
||||||
// instead of showing a single instance until the next refresh.
|
// instead of showing a single instance until the next refresh.
|
||||||
let full = created;
|
let full = created;
|
||||||
try {
|
try {
|
||||||
const fetched = (await fetchEvents([created.id], accountId)) ?? [];
|
const fetched = (await fetchEvents([created.id], writeAccount(scope, accountId))) ?? [];
|
||||||
if (fetched[0]) full = fetched[0];
|
if (fetched[0]) full = fetched[0];
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the echo; the next refresh reconciles.
|
// Keep the echo; the next refresh reconciles.
|
||||||
}
|
}
|
||||||
|
if (!inScope(scope)) return full;
|
||||||
// Map the same way fetchEvents does so the optimistic insert doesn't
|
// 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
|
// collide with the user's own events and stays visible under the right
|
||||||
// calendar filter.
|
// calendar filter.
|
||||||
@@ -1004,8 +1062,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
// A change to an occurrence stays on that occurrence: through its
|
// A change to an occurrence stays on that occurrence: through its
|
||||||
// synthetic id, or as an override on the base event it was expanded
|
// synthetic id, or as an override on the base event it was expanded
|
||||||
// from. Everything else goes to the stored event.
|
// from. Everything else goes to the stored event.
|
||||||
|
const scope = writeScope();
|
||||||
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
||||||
const { storeEvent, realId, accountId } = target;
|
const { storeEvent, realId, accountId } = target;
|
||||||
|
const account = writeAccount(scope, accountId);
|
||||||
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
|
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
|
||||||
// raw server ids the owning account knows.
|
// raw server ids the owning account knows.
|
||||||
const patch: Record<string, unknown> = { ...changes };
|
const patch: Record<string, unknown> = { ...changes };
|
||||||
@@ -1042,15 +1102,16 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
: storeEvent.participants);
|
: storeEvent.participants);
|
||||||
if (target.isOccurrence && storeEvent) {
|
if (target.isOccurrence && storeEvent) {
|
||||||
if (stopsDefaultAlerts && storeEvent.baseEventId) {
|
if (stopsDefaultAlerts && storeEvent.baseEventId) {
|
||||||
await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId);
|
await stopSeriesDefaultAlerts(storeEvent.baseEventId, account, scope);
|
||||||
|
assertInScope(scope);
|
||||||
}
|
}
|
||||||
if (twoSteps) {
|
if (twoSteps) {
|
||||||
try {
|
try {
|
||||||
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
|
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, account, scope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Its quiet override is on the server, and the series' occurrences
|
// 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.
|
// 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;
|
throw err;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1059,19 +1120,25 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
realId,
|
realId,
|
||||||
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
|
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
|
||||||
schedule,
|
schedule,
|
||||||
accountId,
|
account,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (target.isBrowserOccurrence) {
|
} else if (target.isBrowserOccurrence) {
|
||||||
if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId);
|
if (stopsDefaultAlerts) {
|
||||||
|
await stopSeriesDefaultAlerts(realId, account, scope);
|
||||||
|
assertInScope(scope);
|
||||||
|
}
|
||||||
if (twoSteps) {
|
if (twoSteps) {
|
||||||
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule);
|
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule, scope);
|
||||||
} else {
|
} else {
|
||||||
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
|
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule, scope);
|
||||||
}
|
}
|
||||||
} else {
|
} 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({
|
set({
|
||||||
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
||||||
});
|
});
|
||||||
@@ -1089,19 +1156,24 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
deleteEvent: async (id) => {
|
deleteEvent: async (id) => {
|
||||||
// Deleting an occurrence removes just that one: through its synthetic
|
// Deleting an occurrence removes just that one: through its synthetic
|
||||||
// id, or by excluding it on the base event it was expanded from.
|
// id, or by excluding it on the base event it was expanded from.
|
||||||
|
const scope = writeScope();
|
||||||
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
||||||
const { storeEvent, realId, accountId } = target;
|
const { storeEvent, realId, accountId } = target;
|
||||||
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
||||||
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
||||||
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
|
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
|
||||||
if (target.isOccurrence && storeEvent) {
|
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) });
|
set({ events: get().events.filter((e) => e.id !== id) });
|
||||||
} else if (target.isBrowserOccurrence) {
|
} 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) });
|
set({ events: get().events.filter((e) => e.id !== id) });
|
||||||
} else {
|
} 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);
|
knownMasters.delete(id);
|
||||||
// Destroying a master removes every expanded occurrence of it, not
|
// Destroying a master removes every expanded occurrence of it, not
|
||||||
// just the tapped one.
|
// just the tapped one.
|
||||||
@@ -1127,7 +1199,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
// and its SEQUENCE - a change that raises it from a stale one goes out
|
// and its SEQUENCE - a change that raises it from a stale one goes out
|
||||||
// lower than the attendees' copies, which 0.16 refuses.
|
// lower than the attendees' copies, which 0.16 refuses.
|
||||||
const realId = seriesIdOf(event);
|
const realId = seriesIdOf(event);
|
||||||
|
const generation = storeGeneration;
|
||||||
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
|
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];
|
const master = fetched[0];
|
||||||
if (!master) return null;
|
if (!master) return null;
|
||||||
// Known from here on, so a change to it is sent to its own account under
|
// Known from here on, so a change to it is sent to its own account under
|
||||||
@@ -1147,10 +1222,12 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
// isn't in the store; the caller hands it over instead. With scope
|
// isn't in the store; the caller hands it over instead. With scope
|
||||||
// 'series' an occurrence answers for its whole series: the stored event
|
// 'series' an occurrence answers for its whole series: the stored event
|
||||||
// is updated.
|
// is updated.
|
||||||
|
const write = writeScope();
|
||||||
const target = resolveMutationTarget(get().events, eventId, scope);
|
const target = resolveMutationTarget(get().events, eventId, scope);
|
||||||
const storeEvent = target.storeEvent ?? event;
|
const storeEvent = target.storeEvent ?? event;
|
||||||
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
|
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
|
||||||
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
|
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
|
||||||
|
const account = writeAccount(write, accountId);
|
||||||
const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence)
|
const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence)
|
||||||
? target.storeEvent ?? null
|
? target.storeEvent ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -1164,9 +1241,9 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const patch = patchFor({ seriesKeys: true });
|
const patch = patchFor({ seriesKeys: true });
|
||||||
if (!patch) throw new Error('Participant not found on this occurrence');
|
if (!patch) throw new Error('Participant not found on this occurrence');
|
||||||
if (target.isOccurrence) {
|
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 {
|
} else {
|
||||||
await updateBrowserOccurrence(target, patch, true);
|
await updateBrowserOccurrence(target, patch, true, write);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Repair events that are missing the organizer (e.g. imported ones) so
|
// Repair events that are missing the organizer (e.g. imported ones) so
|
||||||
@@ -1175,8 +1252,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
|
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
|
||||||
? replyTo.imip
|
? replyTo.imip
|
||||||
: undefined;
|
: 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);
|
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||||
set({
|
set({
|
||||||
events: get().events.map((e) => {
|
events: get().events.map((e) => {
|
||||||
@@ -1196,11 +1275,12 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
|
|
||||||
importEvents: async (events, calendarId) => {
|
importEvents: async (events, calendarId) => {
|
||||||
if (events.length === 0) return { imported: 0, refused: [] };
|
if (events.length === 0) return { imported: 0, refused: [] };
|
||||||
|
const scope = writeScope();
|
||||||
// Shared calendars live in the owner's account and carry a namespaced
|
// Shared calendars live in the owner's account and carry a namespaced
|
||||||
// store id — resolve the raw server id + owning account so dedup and
|
// store id — resolve the raw server id + owning account so dedup and
|
||||||
// create target the right place.
|
// create target the right place.
|
||||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
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 serverCalendarId = cal?.originalId || calendarId;
|
||||||
const refused: RefusedImport[] = [];
|
const refused: RefusedImport[] = [];
|
||||||
// Stalwart enforces UID uniqueness across calendars (#113):
|
// Stalwart enforces UID uniqueness across calendars (#113):
|
||||||
@@ -1211,7 +1291,9 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
let linked = 0;
|
let linked = 0;
|
||||||
try {
|
try {
|
||||||
const existingIds = await queryEvents([], '', '', accountId);
|
const existingIds = await queryEvents([], '', '', accountId);
|
||||||
|
assertInScope(scope);
|
||||||
const existing = await getEventIdentities(existingIds, accountId);
|
const existing = await getEventIdentities(existingIds, accountId);
|
||||||
|
assertInScope(scope);
|
||||||
const byUid = new Map<string, EventIdentityFields>();
|
const byUid = new Map<string, EventIdentityFields>();
|
||||||
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
|
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
|
||||||
const fresh: Partial<CalendarEvent>[] = [];
|
const fresh: Partial<CalendarEvent>[] = [];
|
||||||
@@ -1222,6 +1304,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (found.calendarIds?.[serverCalendarId]) continue;
|
if (found.calendarIds?.[serverCalendarId]) continue;
|
||||||
|
assertInScope(scope);
|
||||||
try {
|
try {
|
||||||
await apiUpdateEvent(
|
await apiUpdateEvent(
|
||||||
found.id,
|
found.id,
|
||||||
@@ -1236,7 +1319,8 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
toCreate = fresh;
|
toCreate = fresh;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof CalendarAccountChangedError) throw err;
|
||||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||||
}
|
}
|
||||||
const prepared = toCreate.map(prepareImportedEvent);
|
const prepared = toCreate.map(prepareImportedEvent);
|
||||||
@@ -1244,6 +1328,8 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
try {
|
try {
|
||||||
// Batch in chunks of 50 to avoid oversized requests.
|
// Batch in chunks of 50 to avoid oversized requests.
|
||||||
for (let i = 0; i < prepared.length; i += 50) {
|
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 {
|
try {
|
||||||
const result = await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId);
|
const result = await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId);
|
||||||
count += result.created;
|
count += result.created;
|
||||||
@@ -1259,7 +1345,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Also when a chunk was refused: show what did get in.
|
// 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);
|
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||||
await get().refresh();
|
await get().refresh();
|
||||||
}
|
}
|
||||||
@@ -1269,15 +1355,20 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
createCalendar: async (name, color, description) => {
|
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]) });
|
set({ calendars: sortCalendars([...get().calendars, created]) });
|
||||||
requestDeviceSync(CALENDAR_AUTHORITY);
|
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateCalendar: async (id, updates) => {
|
updateCalendar: async (id, updates) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === id);
|
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({
|
set({
|
||||||
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
|
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
|
||||||
});
|
});
|
||||||
@@ -1287,6 +1378,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
reorderCalendars: async (orderedIds) => {
|
reorderCalendars: async (orderedIds) => {
|
||||||
const updates = sortOrderUpdates(get().calendars, orderedIds);
|
const updates = sortOrderUpdates(get().calendars, orderedIds);
|
||||||
if (updates.length === 0) return;
|
if (updates.length === 0) return;
|
||||||
|
const scope = writeScope();
|
||||||
const before = get().calendars;
|
const before = get().calendars;
|
||||||
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
|
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
|
||||||
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
|
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
|
||||||
@@ -1295,23 +1387,28 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const saved = new Map<string, number>();
|
const saved = new Map<string, number>();
|
||||||
try {
|
try {
|
||||||
for (const { id, sortOrder } of updates) {
|
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);
|
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);
|
saved.set(id, sortOrder);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Show the order the server now holds: the moves saved before the
|
// Show the order the server now holds: the moves saved before the
|
||||||
// failure stay, the rest are undone.
|
// failure stay, the rest are undone. Not over the next account's list.
|
||||||
set({ calendars: withSortOrders(saved) });
|
if (inScope(scope)) set({ calendars: withSortOrders(saved) });
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
|
if (saved.size > 0 && inScope(scope)) requestDeviceSync(CALENDAR_AUTHORITY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeCalendar: async (id) => {
|
removeCalendar: async (id) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === id);
|
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({
|
set({
|
||||||
calendars: get().calendars.filter((c) => c.id !== id),
|
calendars: get().calendars.filter((c) => c.id !== id),
|
||||||
events: get().events.filter((e) => !e.calendarIds?.[id]),
|
events: get().events.filter((e) => !e.calendarIds?.[id]),
|
||||||
@@ -1322,9 +1419,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearCalendarEvents: async (id) => {
|
clearCalendarEvents: async (id) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === id);
|
const cal = get().calendars.find((c) => c.id === id);
|
||||||
try {
|
try {
|
||||||
const cleared = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId);
|
const cleared = await apiClearCalendarEvents(cal?.originalId || id, writeAccount(scope, cal?.accountId));
|
||||||
requestDeviceSync(CALENDAR_AUTHORITY);
|
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||||
return cleared;
|
return cleared;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1334,8 +1432,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
shareCalendar: async (id, principalId, rights) => {
|
shareCalendar: async (id, principalId, rights) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === id);
|
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({
|
set({
|
||||||
calendars: get().calendars.map((c) => {
|
calendars: get().calendars.map((c) => {
|
||||||
if (c.id !== id) return c;
|
if (c.id !== id) return c;
|
||||||
@@ -1348,8 +1448,10 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setDefaultCalendar: async (id) => {
|
setDefaultCalendar: async (id) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === id);
|
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({
|
set({
|
||||||
calendars: get().calendars.map((c) => {
|
calendars: get().calendars.map((c) => {
|
||||||
if (c.id === id) return { ...c, isDefault: true };
|
if (c.id === id) return { ...c, isDefault: true };
|
||||||
@@ -1364,20 +1466,24 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
createTask: async (task, calendarId) => {
|
createTask: async (task, calendarId) => {
|
||||||
|
const scope = writeScope();
|
||||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||||
await apiCreateEvent(
|
await apiCreateEvent(
|
||||||
{ ...task, '@type': 'Task' },
|
{ ...task, '@type': 'Task' },
|
||||||
cal?.originalId || calendarId,
|
cal?.originalId || calendarId,
|
||||||
undefined,
|
undefined,
|
||||||
cal?.accountId,
|
writeAccount(scope, cal?.accountId),
|
||||||
);
|
);
|
||||||
await get().fetchTasks();
|
await get().fetchTasks();
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTask: async (id, changes) => {
|
updateTask: async (id, changes) => {
|
||||||
|
const scope = writeScope();
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
const realId = task?.originalId || 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)) });
|
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1395,19 +1501,23 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
// Flip the checkbox right away and put it back if the server refuses.
|
// Flip the checkbox right away and put it back if the server refuses.
|
||||||
const patchTask = (changes: Partial<CalendarEvent>) =>
|
const patchTask = (changes: Partial<CalendarEvent>) =>
|
||||||
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
||||||
|
const scope = writeScope();
|
||||||
patchTask(next);
|
patchTask(next);
|
||||||
try {
|
try {
|
||||||
await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId);
|
await apiUpdateEvent(task.originalId || id, next, undefined, writeAccount(scope, task.accountId));
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTask: async (id) => {
|
deleteTask: async (id) => {
|
||||||
|
const scope = writeScope();
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
const realId = task?.originalId || 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) });
|
set({ tasks: get().tasks.filter((t) => t.id !== id) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user