1 import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
2 import cloneDeep from 'lodash/cloneDeep';
4 import { ICAL_ATTENDEE_STATUS, RECURRING_TYPES, TMP_UID, TMP_UNIQUE_ID } from '@proton/shared/lib/calendar/constants';
5 import { getHasRecurrenceId } from '@proton/shared/lib/calendar/vcalHelper';
6 import type { CalendarEvent } from '@proton/shared/lib/interfaces/calendar';
8 import type { CalendarViewEvent } from '../../containers/calendar/interface';
9 import { getEventReadResult, setEventReadResult, setPartstat } from './eventsCache';
11 export interface CalendarViewEventStore extends CalendarViewEvent {
18 interface EventsState {
19 events: CalendarViewEventStore[];
20 isTmpEventSaving: boolean;
23 const initialState: EventsState = {
25 isTmpEventSaving: false,
28 export const eventsSliceName = 'events';
30 const slice = createSlice({
31 name: eventsSliceName,
34 synchronizeEvents(state, action: PayloadAction<CalendarViewEvent[]>) {
35 const oldEventsMap = new Map<string, CalendarViewEventStore>(
36 state.events.map((event) => [event.uniqueId, event])
39 state.events = action.payload.map((event) => {
40 const oldEvent = oldEventsMap.get(event.uniqueId);
41 const { eventReadResult, calendarData, ...restData } = event.data;
43 // Store eventReadResult in cache if it exists
44 if (eventReadResult) {
45 setEventReadResult(event.uniqueId, eventReadResult);
48 // State is immutable but if we add the event as is in Redux, the payload content will also become
49 // immutable. This is causing issues in other places in the app where we are using the same object
50 // to do some computations.
51 // To avoid this, we need to deep copy the object so that we don't mess up the references
54 data: restData, // Omit eventReadResult && calendarData
57 const eventCopy = cloneDeep(tmpEvent);
59 // Return the updated event, preserving some old event properties
62 isDeleted: oldEvent?.isDeleted,
63 isSaved: oldEvent?.isSaved,
64 isSaving: oldEvent?.isSaving,
65 isDeleting: oldEvent?.isDeleting,
66 } as CalendarViewEventStore;
71 action: PayloadAction<{
72 targetEvent: CalendarViewEvent;
74 recurringType?: RECURRING_TYPES;
77 const { targetEvent, isDeleted, recurringType } = action.payload;
78 const { uniqueId: targetUniqueId } = targetEvent;
79 const targetUID = (targetEvent.data?.eventData as CalendarEvent)?.UID;
81 if (recurringType === RECURRING_TYPES.SINGLE) {
82 // Find and update only the target event (single edit case included)
83 const event = state.events.find((event) => {
84 return event.uniqueId === targetUniqueId;
88 event.isDeleted = isDeleted;
90 } else if (recurringType === RECURRING_TYPES.ALL) {
91 let needRefresh = true;
92 // Update all instances of the recurring event
93 state.events.forEach((event) => {
94 const UID = (event.data?.eventData as CalendarEvent)?.UID;
96 if (UID && UID === targetUID) {
97 const eventReadResult = getEventReadResult(event.uniqueId);
98 const { isAttendee, selfAttendee } = eventReadResult?.result?.[0]?.selfAddressData || {};
99 const isSingleEdit = getHasRecurrenceId(eventReadResult?.result?.[0]?.veventComponent);
101 // When deleting a recurring series as an attendee, single are not deleted.
102 if (isAttendee && isSingleEdit) {
103 // If the user responded to the single edit invite with YES or MAYBE (ACCEPTED OR TENTATIVE),
104 // the user participation status is being reset
106 selfAttendee?.parameters?.partstat === ICAL_ATTENDEE_STATUS.ACCEPTED ||
107 selfAttendee?.parameters?.partstat === ICAL_ATTENDEE_STATUS.TENTATIVE
109 eventReadResult?.result?.[0]?.veventComponent.attendee?.forEach((attendee) => {
110 if (attendee.value === selfAttendee?.value) {
111 if (attendee.parameters) {
112 attendee.parameters.partstat = ICAL_ATTENDEE_STATUS.NEEDS_ACTION;
117 // Do not mark event as deleted
118 setEventReadResult(event.uniqueId, eventReadResult);
122 event.isDeleted = isDeleted;
128 state.events = [...state.events];
130 } else if (recurringType === RECURRING_TYPES.FUTURE) {
131 // Update all future instances of the recurring event
132 state.events.forEach((event) => {
133 if (event.start >= targetEvent.start) {
134 const UID = (event.data?.eventData as CalendarEvent)?.UID;
136 if (UID && UID === targetUID) {
137 event.isDeleted = isDeleted;
145 action: PayloadAction<{
147 targetEvent: CalendarViewEvent;
148 recurringType?: RECURRING_TYPES;
151 const { isDeleting, targetEvent, recurringType } = action.payload;
152 const { uniqueId: targetUniqueId } = targetEvent;
153 const targetUID = (targetEvent.data?.eventData as CalendarEvent)?.UID;
155 state.events.forEach((event) => {
156 const UID = (event.data?.eventData as CalendarEvent)?.UID;
158 if (UID === targetUID) {
160 * Mark the events that actually needs to be deleted as deleting
162 if (recurringType === RECURRING_TYPES.SINGLE) {
163 if (event.uniqueId === targetUniqueId) {
164 event.isDeleting = isDeleting;
166 } else if (recurringType === RECURRING_TYPES.ALL) {
167 const eventReadResult = getEventReadResult(event.uniqueId);
168 const { isAttendee } = eventReadResult?.result?.[0]?.selfAddressData || {};
169 const isSingleEdit = getHasRecurrenceId(eventReadResult?.result?.[0]?.veventComponent);
171 // Single edits are not deleted, but we will reset their partstat
172 if (isAttendee && isSingleEdit) {
176 event.isDeleting = isDeleting;
177 } else if (recurringType === RECURRING_TYPES.FUTURE) {
178 if (event.start >= targetEvent.start) {
179 const UID = (event.data?.eventData as CalendarEvent)?.UID;
181 if (UID && UID === targetUID) {
182 event.isDeleting = isDeleting;
189 markEventAsSaving(state, action: PayloadAction<{ uniqueId: string; isSaving: boolean }>) {
190 // tmp event doesn't not exist in events, so we need to handle it separately
191 if (action.payload.uniqueId === TMP_UNIQUE_ID) {
192 state.isTmpEventSaving = action.payload.isSaving;
195 state.events.forEach((event) => {
196 if (event.uniqueId === action.payload.uniqueId) {
197 event.isSaving = action.payload.isSaving;
201 markEventsAsSaving(state, action: PayloadAction<{ UID: string; isSaving: boolean }>) {
202 // tmp event doesn't not exist in events, so we need to handle it separately
203 if (action.payload.UID === TMP_UID) {
204 state.isTmpEventSaving = action.payload.isSaving;
207 state.events.forEach((event) => {
208 const UID = (event.data?.eventData as CalendarEvent)?.UID;
209 if (UID === action.payload.UID) {
210 event.isSaving = action.payload.isSaving;
214 updateInvite(state, action: PayloadAction<{ ID: string; selfEmail: string; partstat: string }>) {
215 let needRefresh = false;
217 state.events.forEach((event) => {
218 const ID = (event.data?.eventData as CalendarEvent)?.ID;
219 // We use the event ID instead of the event UID since single edit must not change for invite.
220 if (ID && ID === action.payload.ID) {
221 const result = setPartstat(event.uniqueId, action.payload.selfEmail, action.payload.partstat);
223 if (!needRefresh && result) {
230 // Force refresh since changes are saved in a different cache than the Redux store
231 // However, the interface calculates events to display from both caches
232 state.events = [...state.events];
238 export const eventsActions = slice.actions;
239 export const eventsReducer = { [eventsSliceName]: slice.reducer };