Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / calendar.ts
blob8fe2744d2b0ce5b33ab5cea826044c5a04f415e0
1 import { updateCalendarSettings, updateMember } from '@proton/shared/lib/api/calendars';
2 import { getHasUserReachedCalendarsLimit } from '@proton/shared/lib/calendar/calendarLimits';
3 import { CALENDAR_FLAGS, CALENDAR_TYPE, SETTINGS_VIEW } from '@proton/shared/lib/calendar/constants';
4 import { reactivateCalendarsKeys } from '@proton/shared/lib/calendar/crypto/keys/reactivateCalendarKeys';
5 import { getMemberAndAddress } from '@proton/shared/lib/calendar/members';
6 import { getCanWrite } from '@proton/shared/lib/calendar/permissions';
7 import { hasBit, toggleBit } from '@proton/shared/lib/helpers/bitset';
8 import type { Api } from '@proton/shared/lib/interfaces';
9 import type { Address } from '@proton/shared/lib/interfaces/Address';
10 import type { CalendarCreateData } from '@proton/shared/lib/interfaces/calendar/Api';
11 import type {
12     Calendar,
13     CalendarBootstrap,
14     CalendarNotificationSettings,
15     CalendarSettings,
16     CalendarUserSettings,
17     CalendarWithOwnMembers,
18     VisualCalendar,
19 } from '@proton/shared/lib/interfaces/calendar/Calendar';
20 import type { SubscribedCalendar } from '@proton/shared/lib/interfaces/calendar/Subscription';
21 import type { GetAddressKeys } from '@proton/shared/lib/interfaces/hooks/GetAddressKeys';
22 import type { GetAddresses } from '@proton/shared/lib/interfaces/hooks/GetAddresses';
23 import isTruthy from '@proton/utils/isTruthy';
24 import unary from '@proton/utils/unary';
26 export const getIsCalendarActive = ({ Flags } = { Flags: 0 }) => {
27     return hasBit(Flags, CALENDAR_FLAGS.ACTIVE);
30 export const getIsCalendarDisabled = ({ Flags } = { Flags: 0 }) => {
31     return hasBit(Flags, CALENDAR_FLAGS.SELF_DISABLED) || hasBit(Flags, CALENDAR_FLAGS.SUPER_OWNER_DISABLED);
34 export const getDoesCalendarNeedReset = ({ Flags } = { Flags: 0 }) => {
35     return hasBit(Flags, CALENDAR_FLAGS.RESET_NEEDED);
38 export const getDoesCalendarHaveInactiveKeys = ({ Flags } = { Flags: 0 }) => {
39     return hasBit(Flags, CALENDAR_FLAGS.UPDATE_PASSPHRASE);
42 export const getDoesCalendarNeedUserAction = ({ Flags } = { Flags: 0 }) => {
43     return getDoesCalendarNeedReset({ Flags }) || getDoesCalendarHaveInactiveKeys({ Flags });
46 export const getIsCalendarProbablyActive = (calendar = { Flags: 0 }) => {
47     // Calendars are treated as "active" if flags are undefined, this can happen when a new calendar was created and received through the event manager.
48     // In this case, we assume everything went well and treat it as an active calendar.
49     return calendar.Flags === undefined || (!getIsCalendarDisabled(calendar) && getIsCalendarActive(calendar));
52 export const getProbablyActiveCalendars = <T extends Calendar>(calendars: T[] = []): T[] => {
53     return calendars.filter(unary(getIsCalendarProbablyActive));
56 export const getIsPersonalCalendar = (calendar: VisualCalendar | SubscribedCalendar): calendar is VisualCalendar => {
57     return calendar.Type === CALENDAR_TYPE.PERSONAL;
60 export const getIsOwnedCalendar = (calendar: CalendarWithOwnMembers) => {
61     return calendar.Owner.Email === calendar.Members[0].Email;
64 export const getIsSharedCalendar = (calendar: VisualCalendar) => {
65     return getIsPersonalCalendar(calendar) && !getIsOwnedCalendar(calendar);
68 export const getIsSubscribedCalendar = (
69     calendar: Calendar | VisualCalendar | SubscribedCalendar
70 ): calendar is SubscribedCalendar => {
71     return calendar.Type === CALENDAR_TYPE.SUBSCRIPTION;
74 export const getPersonalCalendars = <T extends Calendar>(calendars: T[] = []): T[] => {
75     return calendars.filter(unary(getIsPersonalCalendar));
78 export const getIsCalendarWritable = (calendar: VisualCalendar) => {
79     return getCanWrite(calendar.Permissions) && getIsPersonalCalendar(calendar);
82 export const getWritableCalendars = (calendars: VisualCalendar[]) => {
83     return calendars.filter(unary(getIsCalendarWritable));
86 export const getIsHolidaysCalendar = (calendar: VisualCalendar) => {
87     return calendar.Type === CALENDAR_TYPE.HOLIDAYS;
90 export const getIsUnknownCalendar = (calendar: VisualCalendar) => {
91     const knownTypes = [CALENDAR_TYPE.PERSONAL, CALENDAR_TYPE.SUBSCRIPTION, CALENDAR_TYPE.HOLIDAYS];
93     return !knownTypes.includes(calendar.Type);
96 export const getShowDuration = (calendar: VisualCalendar) => {
97     return getIsCalendarWritable(calendar) && getIsPersonalCalendar(calendar) && getIsOwnedCalendar(calendar);
100 export const groupCalendarsByTaxonomy = (calendars: VisualCalendar[] = []) => {
101     return calendars.reduce<{
102         ownedPersonalCalendars: VisualCalendar[];
103         sharedCalendars: VisualCalendar[];
104         subscribedCalendars: VisualCalendar[];
105         holidaysCalendars: VisualCalendar[];
106         unknownCalendars: VisualCalendar[];
107     }>(
108         (acc, calendar) => {
109             if (getIsSubscribedCalendar(calendar)) {
110                 acc.subscribedCalendars.push(calendar);
111             } else if (getIsPersonalCalendar(calendar)) {
112                 const calendarsGroup = getIsOwnedCalendar(calendar) ? acc.ownedPersonalCalendars : acc.sharedCalendars;
113                 calendarsGroup.push(calendar);
114             } else if (getIsHolidaysCalendar(calendar)) {
115                 acc.holidaysCalendars.push(calendar);
116             } else {
117                 acc.unknownCalendars.push(calendar);
118             }
119             return acc;
120         },
121         {
122             ownedPersonalCalendars: [],
123             sharedCalendars: [],
124             subscribedCalendars: [],
125             holidaysCalendars: [],
126             unknownCalendars: [],
127         }
128     );
131 export const getOwnedPersonalCalendars = (calendars: VisualCalendar[] = []) => {
132     return groupCalendarsByTaxonomy(calendars).ownedPersonalCalendars;
135 export const getSharedCalendars = (calendars: VisualCalendar[] = []) => {
136     return groupCalendarsByTaxonomy(calendars).sharedCalendars;
139 export const getSubscribedCalendars = (calendars: VisualCalendar[] = []) => {
140     return groupCalendarsByTaxonomy(calendars).subscribedCalendars;
143 enum CALENDAR_WEIGHT {
144     PERSONAL = 0,
145     SUBSCRIBED = 1,
146     HOLIDAYS = 2,
147     SHARED = 3,
148     UNKNOWN = 4,
151 const getCalendarWeight = (calendar: VisualCalendar) => {
152     if (getIsPersonalCalendar(calendar)) {
153         return getIsOwnedCalendar(calendar) ? CALENDAR_WEIGHT.PERSONAL : CALENDAR_WEIGHT.SHARED;
154     }
155     if (getIsSubscribedCalendar(calendar)) {
156         return CALENDAR_WEIGHT.SUBSCRIBED;
157     }
158     if (getIsHolidaysCalendar(calendar)) {
159         return CALENDAR_WEIGHT.HOLIDAYS;
160     }
161     return CALENDAR_WEIGHT.UNKNOWN;
165  * Returns calendars sorted by weight and first member priority
167  * @param calendars calendars to sort
168  * @returns sorted calendars
169  */
170 export const sortCalendars = (calendars: VisualCalendar[]) => {
171     return [...calendars].sort((cal1, cal2) => {
172         return getCalendarWeight(cal1) - getCalendarWeight(cal2) || cal1.Priority - cal2.Priority;
173     });
176 const getPreferredCalendar = (calendars: VisualCalendar[] = [], defaultCalendarID: string | null = '') => {
177     if (!calendars.length) {
178         return;
179     }
180     return calendars.find(({ ID }) => ID === defaultCalendarID) || calendars[0];
183 export const getPreferredActiveWritableCalendar = (
184     calendars: VisualCalendar[] = [],
185     defaultCalendarID: string | null = ''
186 ) => {
187     return getPreferredCalendar(getProbablyActiveCalendars(getWritableCalendars(calendars)), defaultCalendarID);
190 export const getDefaultCalendar = (calendars: VisualCalendar[] = [], defaultCalendarID: string | null = '') => {
191     // only active owned personal calendars can be default
192     return getPreferredCalendar(getProbablyActiveCalendars(getOwnedPersonalCalendars(calendars)), defaultCalendarID);
195 export const getVisualCalendar = <T>(calendar: CalendarWithOwnMembers & T): VisualCalendar & T => {
196     const [member] = calendar.Members;
198     return {
199         ...calendar,
200         Name: member.Name,
201         Description: member.Description,
202         Color: member.Color,
203         Display: member.Display,
204         Email: member.Email,
205         Flags: member.Flags,
206         Permissions: member.Permissions,
207         Priority: member.Priority,
208     };
211 export const getVisualCalendars = <T>(calendars: (CalendarWithOwnMembers & T)[]): (VisualCalendar & T)[] =>
212     calendars.map((calendar) => getVisualCalendar(calendar));
214 export const getCanCreateCalendar = ({
215     calendars,
216     ownedPersonalCalendars,
217     disabledCalendars,
218     isFreeUser,
219 }: {
220     calendars: VisualCalendar[];
221     ownedPersonalCalendars: VisualCalendar[];
222     disabledCalendars: VisualCalendar[];
223     isFreeUser: boolean;
224 }) => {
225     const { isCalendarsLimitReached } = getHasUserReachedCalendarsLimit(calendars, isFreeUser);
226     if (isCalendarsLimitReached) {
227         return false;
228     }
229     // TODO: The following if condition is very flaky. We should check that somewhere else
230     const activeCalendars = getProbablyActiveCalendars(ownedPersonalCalendars);
231     const totalActionableCalendars = activeCalendars.length + disabledCalendars.length;
232     if (totalActionableCalendars < ownedPersonalCalendars.length) {
233         // calendar keys need to be reactivated before being able to create a calendar
234         return false;
235     }
237     return true;
240 export const getCalendarWithReactivatedKeys = async ({
241     calendar,
242     api,
243     silenceApi = true,
244     addresses,
245     getAddressKeys,
246     successCallback,
247     handleError,
248 }: {
249     calendar: VisualCalendar;
250     api: Api;
251     silenceApi?: boolean;
252     addresses: Address[];
253     getAddressKeys: GetAddressKeys;
254     successCallback?: () => void;
255     handleError?: (error: any) => void;
256 }) => {
257     if (getDoesCalendarHaveInactiveKeys(calendar)) {
258         try {
259             const possiblySilentApi = <T>(config: any) => api<T>({ ...config, silence: silenceApi });
261             await reactivateCalendarsKeys({
262                 calendars: [calendar],
263                 api: possiblySilentApi,
264                 addresses,
265                 getAddressKeys,
266             });
268             successCallback?.();
270             return {
271                 ...calendar,
272                 Flags: toggleBit(calendar.Flags, CALENDAR_FLAGS.UPDATE_PASSPHRASE),
273                 Members: calendar.Members.map((member) => {
274                     const newMember = { ...member };
275                     if (newMember.Email === calendar.Email) {
276                         newMember.Flags = toggleBit(calendar.Flags, CALENDAR_FLAGS.UPDATE_PASSPHRASE);
277                     }
278                     return newMember;
279                 }),
280             };
281         } catch (e) {
282             handleError?.(e);
284             return calendar;
285         }
286     }
288     return calendar;
291 export const DEFAULT_CALENDAR_USER_SETTINGS: CalendarUserSettings = {
292     WeekLength: 7,
293     DisplayWeekNumber: 1,
294     DefaultCalendarID: null,
295     AutoDetectPrimaryTimezone: 1,
296     PrimaryTimezone: 'UTC',
297     DisplaySecondaryTimezone: 0,
298     SecondaryTimezone: null,
299     ViewPreference: SETTINGS_VIEW.WEEK,
300     InviteLocale: null,
301     AutoImportInvite: 0,
304 const getHasChangedCalendarMemberData = (calendarPayload: CalendarCreateData, calendar: VisualCalendar) => {
305     const { Name: oldName, Description: oldDescription, Color: oldColor, Display: oldDisplay } = calendar;
306     const { Name: newName, Description: newDescription, Color: newColor, Display: newDisplay } = calendarPayload;
308     return (
309         oldColor.toLowerCase() !== newColor.toLowerCase() ||
310         oldDisplay !== newDisplay ||
311         oldName !== newName ||
312         oldDescription !== newDescription
313     );
316 const getHasChangedCalendarNotifications = (
317     newNotifications: CalendarNotificationSettings[],
318     oldNotifications: CalendarNotificationSettings[]
319 ) => {
320     return (
321         newNotifications.length !== oldNotifications.length ||
322         newNotifications.some(
323             ({ Type: newType, Trigger: newTrigger }) =>
324                 !oldNotifications.find(
325                     ({ Type: oldType, Trigger: oldTrigger }) => oldType === newType && oldTrigger === newTrigger
326                 )
327         )
328     );
331 export type EditableCalendarSettings = Pick<
332     CalendarSettings,
333     'DefaultEventDuration' | 'DefaultPartDayNotifications' | 'DefaultFullDayNotifications' | 'MakesUserBusy'
336 const getHasChangedCalendarSettings = (
337     newSettings: Required<EditableCalendarSettings>,
338     oldSettings?: CalendarSettings
339 ) => {
340     if (!oldSettings) {
341         // we should not fall in here. If we do, assume changes are needed
342         return true;
343     }
344     const {
345         DefaultEventDuration: newDuration,
346         DefaultPartDayNotifications: newPartDayNotifications,
347         DefaultFullDayNotifications: newFullDayNotifications,
348         MakesUserBusy: newMakesUserBusy,
349     } = newSettings;
350     const {
351         DefaultEventDuration: oldDuration,
352         DefaultPartDayNotifications: oldPartDayNotifications,
353         DefaultFullDayNotifications: oldFullDayNotifications,
354         MakesUserBusy: oldMakesUserBusy,
355     } = oldSettings;
357     return (
358         newDuration !== oldDuration ||
359         getHasChangedCalendarNotifications(newPartDayNotifications, oldPartDayNotifications) ||
360         getHasChangedCalendarNotifications(newFullDayNotifications, oldFullDayNotifications) ||
361         newMakesUserBusy !== oldMakesUserBusy
362     );
364 export const updateCalendar = async (
365     calendar: VisualCalendar,
366     calendarPayload: CalendarCreateData,
367     calendarSettingsPayload: Required<EditableCalendarSettings>,
368     getCalendarBootstrap: (calendarID: string) => Promise<CalendarBootstrap>,
369     getAddresses: GetAddresses,
370     api: Api
371 ) => {
372     const calendarID = calendar.ID;
373     const { Color, Display, Description, Name } = calendarPayload;
374     const [{ ID: memberID }] = getMemberAndAddress(await getAddresses(), calendar.Members);
375     const hasChangedMemberData = getHasChangedCalendarMemberData(calendarPayload, calendar);
376     const calendarBootstrap = await getCalendarBootstrap(calendarID);
377     const hasChangedSettings = getHasChangedCalendarSettings(
378         calendarSettingsPayload,
379         calendarBootstrap.CalendarSettings
380     );
382     await Promise.all(
383         [
384             hasChangedMemberData && api(updateMember(calendarID, memberID, { Display, Color, Description, Name })),
385             hasChangedSettings && api(updateCalendarSettings(calendarID, calendarSettingsPayload)),
386         ].filter(isTruthy)
387     );