1 import { c } from 'ttag';
3 import { getIsAddressExternal } from '@proton/shared/lib/helpers/address';
4 import { unescape } from '@proton/shared/lib/sanitize/escape';
5 import isTruthy from '@proton/utils/isTruthy';
6 import unary from '@proton/utils/unary';
8 import { MIME_TYPES } from '../../constants';
9 import { addDays, format as formatUTC } from '../../date-fns-utc';
10 import type { Options } from '../../date-fns-utc/format';
11 import { formatTimezoneOffset, getTimezoneOffset, toUTCDate } from '../../date/timezone';
15 canonicalizeEmailByGuess,
16 canonicalizeInternalEmail,
17 } from '../../helpers/email';
18 import { omit, pick } from '../../helpers/object';
19 import { getCurrentUnixTimestamp } from '../../helpers/time';
20 import { dateLocale } from '../../i18n';
21 import type { Address } from '../../interfaces';
29 VcalOrganizerProperty,
34 VcalVtimezoneComponent,
35 } from '../../interfaces/calendar';
36 import type { ContactEmail } from '../../interfaces/contacts';
37 import type { GetVTimezonesMap } from '../../interfaces/hooks/GetVTimezonesMap';
38 import type { RequireSome } from '../../interfaces/utils';
39 import { getSupportedPlusAlias } from '../../mail/addresses';
40 import { MESSAGE_FLAGS } from '../../mail/constants';
41 import { RE_PREFIX, formatSubject } from '../../mail/messages';
42 import { getAttendeeEmail, toIcsPartstat } from '../attendees';
47 NOTIFICATION_TYPE_API,
49 } from '../constants';
50 import { getSelfAddressData } from '../deserialize';
51 import { getDisplayTitle } from '../helper';
52 import { getSupportedStringValue } from '../icsSurgery/vcal';
53 import { getIsRruleEqual } from '../recurrence/rruleEqual';
54 import { stripAllTags } from '../sanitize';
55 import { fromTriggerString, serialize } from '../vcal';
56 import { getAllDayInfo, getHasModifiedDateTimes, getIsEquivalentAttendee, propertyToUTCDate } from '../vcalConverter';
63 } from '../vcalHelper';
70 withoutRedundantDtEnd,
71 withoutRedundantRrule,
72 } from '../veventHelper';
74 export const getParticipantHasAddressID = (
75 participant: Participant
76 ): participant is RequireSome<Participant, 'addressID'> => {
77 return !!participant.addressID;
80 export const getParticipant = ({
90 participant: VcalAttendeeProperty | VcalOrganizerProperty;
91 contactEmails: ContactEmail[];
92 selfAddress?: Address;
93 selfAttendee?: VcalAttendeeProperty;
96 calendarAttendees?: Attendee[];
97 xYahooUserStatus?: string;
99 const emailAddress = getAttendeeEmail(participant);
100 const canonicalInternalEmail = canonicalizeInternalEmail(emailAddress);
101 const canonicalEmail = canonicalizeEmailByGuess(emailAddress);
102 const isSelf = selfAddress && canonicalizeInternalEmail(selfAddress.Email) === canonicalInternalEmail;
103 const isYou = emailTo ? canonicalizeInternalEmail(emailTo) === canonicalInternalEmail : isSelf;
104 const contact = contactEmails.find(({ Email }) => canonicalizeEmail(Email) === canonicalEmail);
105 const participantName = participant?.parameters?.cn || emailAddress;
106 const displayName = (isSelf && selfAddress?.DisplayName) || contact?.Name || participantName;
107 const result: Participant = {
108 vcalComponent: participant,
109 name: participantName,
111 partstat: getAttendeePartstat(participant, xYahooUserStatus),
112 displayName: isYou ? c('Participant name').t`You` : displayName,
113 displayEmail: emailAddress,
115 const { role, email, 'x-pm-token': token } = (participant as VcalAttendeeProperty).parameters || {};
116 const calendarAttendee = token ? calendarAttendees?.find(({ Token }) => Token === token) : undefined;
118 result.role = getAttendeeRole(participant);
121 result.displayEmail = email;
124 result.token = token;
126 if (calendarAttendee) {
127 result.updateTime = calendarAttendee.UpdateTime;
128 result.attendeeID = calendarAttendee.ID;
130 if (selfAddress && selfAttendee && isSelf) {
131 result.addressID = selfAddress.ID;
132 // Use Proton form of the email address (important for sending email)
133 result.emailAddress = getSupportedPlusAlias({
134 selfAttendeeEmail: getAttendeeEmail(selfAttendee),
135 selfAddressEmail: selfAddress.Email,
137 // Use Proton name when sending out the email
138 result.name = selfAddress.DisplayName || participantName;
140 if (index !== undefined) {
141 result.attendeeIndex = index;
147 * Build ad-hoc participant data for a party crasher
148 * (to fake a party crasher actually being in the ICS)
150 export const buildPartyCrasherParticipantData = (
152 ownAddresses: Address[],
153 contactEmails: ContactEmail[],
154 attendees: VcalAttendeeProperty[]
155 ): { participant?: Participant; selfAttendee: VcalAttendeeProperty; selfAddress: Address } | undefined => {
156 let isCatchAllPartyCrasher = false;
157 const selfInternalAddresses = ownAddresses.filter((address) => !getIsAddressExternal(address));
159 const canonicalizedOriginalTo = canonicalizeInternalEmail(originalTo);
160 let selfAddress = selfInternalAddresses.find(
161 ({ Email }) => canonicalizeInternalEmail(Email) === canonicalizedOriginalTo
165 const catchAllAddress = selfInternalAddresses.find(({ CatchAll }) => CatchAll);
166 if (catchAllAddress) {
167 // if any address is catch-all, that will be detected as party crasher
168 isCatchAllPartyCrasher = true;
169 selfAddress = catchAllAddress;
175 const fakeOriginalTo = isCatchAllPartyCrasher ? selfAddress.Email : originalTo;
176 const selfAttendee: VcalAttendeeProperty = {
177 value: buildMailTo(fakeOriginalTo),
180 partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
185 participant: getParticipant({
186 participant: selfAttendee,
190 index: attendees.length,
191 emailTo: fakeOriginalTo,
198 interface CreateInviteVeventParams {
200 attendeesTo?: VcalAttendeeProperty[];
201 vevent: VcalVeventComponent;
202 keepDtstamp?: boolean;
205 export const createInviteVevent = ({ method, attendeesTo, vevent, keepDtstamp }: CreateInviteVeventParams) => {
206 if ([ICAL_METHOD.REPLY, ICAL_METHOD.CANCEL].includes(method) && attendeesTo?.length) {
207 const propertiesToKeepForCancel: (keyof VcalVeventComponent)[] = ['x-pm-shared-event-id'];
208 const propertiesToKeepForReply: (keyof VcalVeventComponent)[] = ['x-pm-proton-reply', 'exdate'];
209 const keepDtStampProperty: (keyof VcalVeventComponent)[] = ['dtstamp'];
211 // only put RFC-mandatory fields to make reply as short as possible
212 // rrule, summary and location are also included for a better UI in the external provider widget
213 const propertiesToKeep: (keyof VcalVeventComponent)[] = [
223 ...(keepDtstamp ? keepDtStampProperty : []),
224 ...(method === ICAL_METHOD.CANCEL ? propertiesToKeepForCancel : []),
225 ...(method === ICAL_METHOD.REPLY ? propertiesToKeepForReply : []),
228 const attendee = attendeesTo.map(({ value, parameters }) => {
229 const { partstat } = parameters || {};
230 if (method === ICAL_METHOD.REPLY) {
232 throw new Error('Cannot reply without participant status');
236 parameters: { partstat },
242 const veventWithoutRedundantDtEnd = withoutRedundantDtEnd(
244 ...pick(vevent, propertiesToKeep),
250 return method === ICAL_METHOD.REPLY
251 ? withoutRedundantRrule(veventWithoutRedundantDtEnd)
252 : veventWithoutRedundantDtEnd;
255 if (method === ICAL_METHOD.REQUEST) {
257 const propertiesToOmit: (keyof VcalVeventComponent)[] = ['components', 'x-pm-proton-reply', 'color'];
258 // use current time as dtstamp unless indicated otherwise
260 propertiesToOmit.push('dtstamp');
262 // SUMMARY is mandatory in a REQUEST ics
263 const veventWithSummary = withSummary(vevent);
264 return withoutRedundantDtEnd(withDtstamp(omit(veventWithSummary, propertiesToOmit) as VcalVeventComponent));
268 interface CreateInviteIcsParams {
271 vevent: VcalVeventComponent;
272 attendeesTo?: VcalAttendeeProperty[];
273 vtimezones?: VcalVtimezoneComponent[];
274 sharedEventID?: string;
275 keepDtstamp?: boolean;
278 export const createInviteIcs = ({
285 }: CreateInviteIcsParams): string => {
286 // use current time as dtstamp
287 const inviteVevent = createInviteVevent({ method, vevent, attendeesTo, keepDtstamp });
289 throw new Error('Invite vevent failed to be created');
291 const inviteVcal: RequireSome<VcalVcalendar, 'components'> = {
292 component: 'vcalendar',
293 components: [inviteVevent],
294 prodid: { value: prodId },
295 version: { value: '2.0' },
296 method: { value: method },
297 calscale: { value: 'GREGORIAN' },
300 if (vtimezones?.length) {
301 inviteVcal.components = [...vtimezones, ...inviteVcal.components];
304 return serialize(inviteVcal);
307 export const findAttendee = (email: string, attendees: VcalAttendeeProperty[] = []) => {
308 // treat all emails as internal. This is not fully correct (TO BE IMPROVED),
309 // but it's better to have some false positives rather than many false negatives
310 const canonicalEmail = canonicalizeInternalEmail(email);
311 const index = attendees.findIndex(
312 (attendee) => canonicalizeInternalEmail(getAttendeeEmail(attendee)) === canonicalEmail
314 const attendee = index !== -1 ? attendees[index] : undefined;
315 return { index, attendee };
318 export const getVeventWithDefaultCalendarAlarms = (vevent: VcalVeventComponent, calendarSettings: CalendarSettings) => {
319 const { components } = vevent;
321 const isAllDay = getIsAllDay(vevent);
322 const notifications = isAllDay
323 ? calendarSettings.DefaultFullDayNotifications
324 : calendarSettings.DefaultPartDayNotifications;
325 const valarmComponents = notifications.map<VcalValarmComponent>(({ Trigger, Type }) => ({
327 action: { value: Type === NOTIFICATION_TYPE_API.EMAIL ? ICAL_ALARM_ACTION.EMAIL : ICAL_ALARM_ACTION.DISPLAY },
328 trigger: { value: fromTriggerString(Trigger) },
333 components: components ? components.concat(valarmComponents) : valarmComponents,
337 export const getInvitedVeventWithAlarms = ({
341 oldHasDefaultNotifications,
344 vevent: VcalVeventComponent;
345 partstat: ICAL_ATTENDEE_STATUS;
346 calendarSettings?: CalendarSettings;
347 oldHasDefaultNotifications?: boolean;
348 oldPartstat?: ICAL_ATTENDEE_STATUS;
350 const { components } = vevent;
351 const alarmComponents = components?.filter((component) => getIsAlarmComponent(component));
352 const otherComponents = components?.filter((component) => !getIsAlarmComponent(component));
354 if ([ICAL_ATTENDEE_STATUS.DECLINED, ICAL_ATTENDEE_STATUS.NEEDS_ACTION].includes(partstat)) {
355 // remove all alarms in this case
356 if (otherComponents?.length) {
358 vevent: { ...vevent, components: otherComponents },
359 hasDefaultNotifications: false,
363 vevent: { ...vevent, components: [] },
364 hasDefaultNotifications: false,
367 const leaveAlarmsUntouched = oldPartstat
368 ? [ICAL_ATTENDEE_STATUS.ACCEPTED, ICAL_ATTENDEE_STATUS.TENTATIVE].includes(oldPartstat) ||
369 !!alarmComponents?.length
371 if (leaveAlarmsUntouched) {
374 hasDefaultNotifications: oldHasDefaultNotifications || false,
377 // otherwise add default calendar alarms
378 if (!calendarSettings) {
379 throw new Error('Cannot retrieve calendar default notifications');
383 vevent: getVeventWithDefaultCalendarAlarms(vevent, calendarSettings),
384 hasDefaultNotifications: true,
388 export const getSelfAttendeeToken = (vevent?: VcalVeventComponent, addresses: Address[] = []) => {
389 if (!vevent?.attendee) {
392 const { selfAddress, selfAttendeeIndex } = getSelfAddressData({
393 organizer: vevent.organizer,
394 attendees: vevent.attendee,
397 if (!selfAddress || selfAttendeeIndex === undefined) {
400 return vevent.attendee[selfAttendeeIndex].parameters?.['x-pm-token'];
403 export const generateVtimezonesComponents = async (
404 { dtstart, dtend, 'recurrence-id': recurrenceId, exdate = [] }: VcalVeventComponent,
405 getVTimezones: GetVTimezonesMap
406 ): Promise<VcalVtimezoneComponent[]> => {
407 const timezones = [dtstart, dtend, recurrenceId, ...exdate]
409 .map(unary(getPropertyTzid))
412 const vtimezonesObject = await getVTimezones(timezones);
413 return Object.values(vtimezonesObject)
415 .map(({ vtimezone }) => vtimezone);
418 const getFormattedDateInfo = (vevent: VcalVeventComponent, options: Options = { locale: dateLocale }) => {
419 const { dtstart, dtend } = vevent;
420 const { isAllDay, isSingleAllDay } = getAllDayInfo(dtstart, dtend);
423 formattedStart: formatUTC(toUTCDate(dtstart.value), 'cccc PPP', options),
424 formattedEnd: dtend ? formatUTC(addDays(toUTCDate(dtend.value), -1), 'cccc PPP', options) : undefined,
429 const formattedStartDateTime = formatUTC(toUTCDate(dtstart.value), 'cccc PPPp', options);
430 const formattedEndDateTime = dtend ? formatUTC(toUTCDate(dtend.value), 'cccc PPPp', options) : undefined;
431 const { offset: startOffset } = getTimezoneOffset(propertyToUTCDate(dtstart), getPropertyTzid(dtstart) || 'UTC');
432 const { offset: endOffset } = dtend
433 ? getTimezoneOffset(propertyToUTCDate(dtend), getPropertyTzid(dtend) || 'UTC')
435 const formattedStartOffset = `GMT${formatTimezoneOffset(startOffset)}`;
436 const formattedEndOffset = `GMT${formatTimezoneOffset(endOffset)}`;
438 formattedStart: `${formattedStartDateTime} (${formattedStartOffset})`,
439 formattedEnd: formattedEndDateTime ? `${formattedEndDateTime} (${formattedEndOffset})` : undefined,
445 export const generateEmailSubject = ({
452 vevent: VcalVeventComponent;
453 isCreateEvent?: boolean;
454 dateFormatOptions?: Options;
456 const { formattedStart, isSingleAllDay } = getFormattedDateInfo(withoutRedundantDtEnd(vevent), dateFormatOptions);
457 const { REQUEST, CANCEL, REPLY } = ICAL_METHOD;
459 if (method === REQUEST) {
460 const isSingleEdit = getHasRecurrenceId(vevent);
461 if (isSingleAllDay) {
462 return isCreateEvent && !isSingleEdit
463 ? c('Email subject').t`Invitation for an event on ${formattedStart}`
464 : c('Email subject').t`Update for an event on ${formattedStart}`;
467 return isCreateEvent && !isSingleEdit
468 ? c('Email subject').t`Invitation for an event starting on ${formattedStart}`
469 : c('Email subject').t`Update for an event starting on ${formattedStart}`;
472 if (method === CANCEL) {
473 return isSingleAllDay
474 ? c('Email subject').t`Cancellation of an event on ${formattedStart}`
475 : c('Email subject').t`Cancellation of an event starting on ${formattedStart}`;
478 if (method === REPLY) {
479 return isSingleAllDay
480 ? formatSubject(c('Email subject').t`Invitation for an event on ${formattedStart}`, RE_PREFIX)
481 : formatSubject(c('Email subject').t`Invitation for an event starting on ${formattedStart}`, RE_PREFIX);
484 throw new Error('Unexpected method');
487 const getWhenText = (vevent: VcalVeventComponent, dateFormatOptions?: Options) => {
488 const { formattedStart, formattedEnd, isAllDay, isSingleAllDay } = getFormattedDateInfo(vevent, dateFormatOptions);
490 return isSingleAllDay || !formattedEnd
491 ? c('Email body for invitation (date part)').t`TIME:
492 ${formattedStart} (all day)`
493 : c('Email body for invitation (date part)').t`TIME:
494 ${formattedStart} - ${formattedEnd}`;
497 ? c('Email body for invitation (date part)').t`TIME:
498 ${formattedStart} - ${formattedEnd}`
499 : c('Email body for invitation (date part)').t`TIME:
503 const buildUpdatedFieldText = (updatedBodyText: string, updatedFieldText: string, field: string) => {
504 // translator: text to display in the message body of an updated event when a certain field has been removed
505 const removedFieldText = c('Email body for invitation (event details part)').t`Removed`;
507 // If field is updated and empty, the user removed it.
508 // In that case we want to display a text to let the user know that the field has been removed.
509 const hasRemovedField = updatedFieldText === '';
511 if (updatedBodyText === '') {
512 return hasRemovedField
517 return hasRemovedField
518 ? `${updatedBodyText}
522 : `${updatedBodyText}
524 ${updatedFieldText}`;
527 const getUpdateEmailBodyText = ({
535 vevent: VcalVeventComponent;
536 oldVevent: VcalVeventComponent;
539 locationText: string;
540 descriptionText: string;
542 const hasSameTime = !getHasModifiedDateTimes(vevent, oldVevent);
543 const hasSameTitle = getSupportedStringValue(vevent.summary) === getSupportedStringValue(oldVevent.summary);
544 const hasSameLocation = getSupportedStringValue(vevent.location) === getSupportedStringValue(oldVevent.location);
545 const hasSameDescription =
546 getSupportedStringValue(vevent.description) === getSupportedStringValue(oldVevent.description);
548 let updatedBodyText = '';
550 updatedBodyText = buildUpdatedFieldText(updatedBodyText, eventTitle, 'TITLE');
553 updatedBodyText = buildUpdatedFieldText(updatedBodyText, whenText, 'TIME');
555 if (!hasSameLocation) {
556 updatedBodyText = buildUpdatedFieldText(updatedBodyText, locationText, 'LOCATION');
558 if (!hasSameDescription) {
559 updatedBodyText = buildUpdatedFieldText(updatedBodyText, descriptionText, 'DESCRIPTION');
561 return updatedBodyText;
564 const getEmailBodyTexts = (
565 vevent: VcalVeventComponent,
566 oldVevent?: VcalVeventComponent,
567 dateFormatOptions?: Options
569 const { summary, location, description } = vevent;
570 const eventTitle = getDisplayTitle(summary?.value);
571 const eventLocation = location?.value;
572 const eventDescription = description?.value;
574 const whenText = getWhenText(vevent, dateFormatOptions);
575 const locationText = eventLocation
576 ? c('Email body for invitation (location part)').t`LOCATION:
579 const descriptionText = eventDescription
580 ? c('Email body for description (description part)').t`DESCRIPTION:
583 const locationAndDescriptionText =
584 locationText && descriptionText
588 : `${locationText || descriptionText}`;
589 const eventDetailsText = locationAndDescriptionText
592 ${locationAndDescriptionText}`
595 const titleText = `TITLE:
597 const updateEventDetailsText = oldVevent
598 ? getUpdateEmailBodyText({
601 eventTitle: titleText,
608 return { eventTitle, eventDetailsText, updateEventDetailsText };
611 export const generateEmailBody = ({
622 vevent: VcalVeventComponent;
623 oldVevent?: VcalVeventComponent;
624 isCreateEvent?: boolean;
625 emailAddress?: string;
626 partstat?: ICAL_ATTENDEE_STATUS;
628 recurringType?: RECURRING_TYPES;
630 const { eventTitle, eventDetailsText, updateEventDetailsText } = getEmailBodyTexts(
631 withoutRedundantDtEnd(vevent),
632 oldVevent ? withoutRedundantDtEnd(oldVevent) : undefined,
635 const hasUpdatedText = updateEventDetailsText && updateEventDetailsText !== '';
637 if (method === ICAL_METHOD.REQUEST) {
638 if (getHasRecurrenceId(vevent)) {
639 return hasUpdatedText
640 ? c('Email body for invitation').t`This event occurrence was updated. Here's what changed:
642 ${updateEventDetailsText}`
643 : c('Email body for invitation').t`This event occurrence was updated.`;
645 if (recurringType === RECURRING_TYPES.ALL) {
646 return hasUpdatedText
647 ? c('Email body for invitation').t`All events in this series were updated. Here's what changed:
649 ${updateEventDetailsText}`
650 : c('Email body for invitation').t`All events in this series were updated.`;
653 return c('Email body for invitation').t`You are invited to ${eventTitle}.
655 ${eventDetailsText}`;
657 return hasUpdatedText
658 ? c('Email body for invitation').t`This event was updated. Here's what changed:
660 ${updateEventDetailsText}`
661 : c('Email body for invitation').t`This event was updated.`;
663 if (method === ICAL_METHOD.CANCEL) {
664 if (getHasRecurrenceId(vevent)) {
665 return c('Email body for invitation').t`This event occurrence was canceled.`;
667 return c('Email body for invitation').t`${eventTitle} was canceled.`;
669 if (method === ICAL_METHOD.REPLY) {
670 if (!partstat || !emailAddress) {
671 throw new Error('Missing parameters for reply body');
673 if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
674 return c('Email body for response to invitation')
675 .t`${emailAddress} accepted your invitation to ${eventTitle}`;
677 if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
678 return c('Email body for response to invitation')
679 .t`${emailAddress} tentatively accepted your invitation to ${eventTitle}`;
681 if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
682 return c('Email body for response to invitation')
683 .t`${emailAddress} declined your invitation to ${eventTitle}`;
685 throw new Error('Unanswered partstat');
687 throw new Error('Unexpected method');
690 export const getIcsMessageWithPreferences = (globalSign: number) => ({
691 MIMEType: MIME_TYPES.PLAINTEXT,
692 Flags: globalSign ? MESSAGE_FLAGS.FLAG_SIGN : undefined,
695 export const getHasUpdatedInviteData = ({
698 hasModifiedDateTimes,
701 newVevent: VcalVeventComponent;
702 oldVevent?: VcalVeventComponent;
703 hasModifiedDateTimes?: boolean;
704 hasModifiedRrule?: boolean;
709 const hasUpdatedDateTimes = hasModifiedDateTimes ?? getHasModifiedDateTimes(newVevent, oldVevent);
711 const keys: VcalComponentKeys[] = ['summary', 'description', 'location'];
712 const hasUpdatedTitleDescriptionOrLocation = keys.some((key) => {
713 const newValue = getSupportedStringValue(newVevent[key] as VcalStringProperty);
714 const oldValue = getSupportedStringValue(oldVevent[key] as VcalStringProperty);
716 // Sanitize to better diff detection, and unescape characters
717 // `oldvalue` is the original event value, so it can contain HTML tags
718 // `newValue` is supposed to be already sanitized
719 // Always doing the computation because sometimes the new values is undefined, but not the old one
720 const cleanedNewValue = stripAllTags(unescape(newValue || '')).trim();
721 const cleanedOldValue = stripAllTags(unescape(oldValue || '')).trim();
722 return cleanedNewValue !== cleanedOldValue;
725 const hasUpdatedRrule = hasModifiedRrule ?? !getIsRruleEqual(newVevent.rrule, oldVevent.rrule);
726 return hasUpdatedDateTimes || hasUpdatedTitleDescriptionOrLocation || hasUpdatedRrule;
729 export const getInviteVeventWithUpdatedParstats = (
730 newVevent: VcalVeventComponent,
731 oldVevent: VcalVeventComponent,
734 if (method === ICAL_METHOD.REQUEST && getSequence(newVevent) > getSequence(oldVevent)) {
735 if (!newVevent.attendee?.length) {
736 return { ...newVevent };
738 const withResetPartstatAttendees = newVevent.attendee.map((attendee) => ({
741 ...attendee.parameters,
742 partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
745 return { ...newVevent, attendee: withResetPartstatAttendees };
747 return { ...newVevent };
750 export const getResetPartstatActions = (
751 singleEdits: CalendarEvent[],
753 partstat: ICAL_ATTENDEE_STATUS
755 const updateTime = getCurrentUnixTimestamp();
756 const updateActions = singleEdits
758 if (getIsEventCancelled(event)) {
759 // no need to reset the partsat as it should have been done already
762 const selfAttendee = event.Attendees.find(({ Token }) => Token === token);
766 const oldPartstat = toIcsPartstat(selfAttendee.Status);
767 if ([ICAL_ATTENDEE_STATUS.NEEDS_ACTION, partstat].includes(oldPartstat)) {
768 // no need to reset the partstat as it's already reset or it coincides with the new partstat
772 attendeeID: selfAttendee.ID,
774 calendarID: event.CalendarID,
776 partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
777 color: event.Color ? event.Color : undefined,
781 const updatePartstatActions = updateActions.map((action) => omit(action, ['color']));
782 const updatePersonalPartActions = updateActions
783 .map(({ eventID, calendarID, color }) => ({ eventID, calendarID, color }))
786 return { updatePartstatActions, updatePersonalPartActions };
789 export const getHasNonCancelledSingleEdits = (singleEdits: CalendarEvent[]) => {
790 return singleEdits.some((event) => !getIsEventCancelled(event));
793 export const getMustResetPartstat = (singleEdits: CalendarEvent[], token?: string, partstat?: ICAL_ATTENDEE_STATUS) => {
794 if (!token || !partstat) {
797 return singleEdits.some((event) => {
798 if (getIsEventCancelled(event)) {
801 const selfAttendee = event.Attendees.find(({ Token }) => Token === token);
805 const oldPartstat = toIcsPartstat(selfAttendee.Status);
806 if ([ICAL_ATTENDEE_STATUS.NEEDS_ACTION, partstat].includes(oldPartstat)) {
813 export const getHasModifiedAttendees = ({
819 veventIcs: VcalVeventComponent;
820 veventApi: VcalVeventComponent;
821 attendeeIcs: Participant;
822 attendeeApi: Participant;
824 const { attendee: attendeesIcs } = veventIcs;
825 const { attendee: attendeesApi } = veventApi;
827 return !!attendeesApi;
829 if (!attendeesApi || attendeesApi.length !== attendeesIcs.length) {
832 // We check if attendees other than the invitation attendees have been modified
833 const otherAttendeesIcs = attendeesIcs.filter(
834 (attendee) => canonicalizeEmail(getAttendeeEmail(attendee)) !== canonicalizeEmail(attendeeIcs.emailAddress)
836 const otherAttendeesApi = attendeesApi.filter(
837 (attendee) => canonicalizeEmail(getAttendeeEmail(attendee)) !== canonicalizeEmail(attendeeApi.emailAddress)
839 return otherAttendeesIcs.reduce((acc, attendee) => {
843 const index = otherAttendeesApi.findIndex((oldAttendee) => getIsEquivalentAttendee(oldAttendee, attendee));
847 otherAttendeesApi.splice(index, 1);