Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / calendar / src / app / components / eventModal / inputs / ParticipantsInput.tsx
blob34d78fc943a9e13075a0d329f5f359d02a47d894
1 import { memo, useMemo } from 'react';
3 import { c, msgid } from 'ttag';
5 import { Button } from '@proton/atoms';
6 import {
7     AddressesAutocompleteTwo,
8     Alert,
9     Details,
10     Icon,
11     Summary,
12     useBusySlotsAvailable,
13     useContactEmailsCache,
14 } from '@proton/components';
15 import { useMailSettings } from '@proton/mail/mailSettings/hooks';
16 import { emailToAttendee } from '@proton/shared/lib/calendar/attendees';
17 import type { VIEWS } from '@proton/shared/lib/calendar/constants';
18 import { ICAL_ATTENDEE_ROLE } from '@proton/shared/lib/calendar/constants';
19 import { getSelfSendAddresses } from '@proton/shared/lib/helpers/address';
20 import {
21     CANONICALIZE_SCHEME,
22     canonicalizeEmail,
23     canonicalizeInternalEmail,
24     validateEmailAddress,
25 } from '@proton/shared/lib/helpers/email';
26 import type { Address, Recipient } from '@proton/shared/lib/interfaces';
27 import type { AttendeeModel, OrganizerModel } from '@proton/shared/lib/interfaces/calendar';
28 import { inputToRecipient } from '@proton/shared/lib/mail/recipient';
29 import uniqueBy from '@proton/utils/uniqueBy';
31 import { selectDisplayAvailabilityUnknown } from '../../../store/busySlots/busySlotsSelectors';
32 import { useCalendarSelector } from '../../../store/hooks';
33 import { getParticipantsError } from '../helpers';
34 import OrganizerRow from '../rows/OrganizerRow';
35 import ParticipantRows from '../rows/ParticipantRows';
37 const { REQUIRED, OPTIONAL } = ICAL_ATTENDEE_ROLE;
39 interface Props {
40     value: AttendeeModel[];
41     isOwnedCalendar: boolean;
42     addresses: Address[];
43     organizer?: OrganizerModel;
44     displayBusySlots: boolean;
45     id: string;
46     placeholder: string;
47     className?: string;
48     onChange: (recipients: AttendeeModel[]) => void;
49     setParticipantError?: (value: boolean) => void;
50     collapsible?: boolean;
51     onDisplayBusySlots?: () => void;
52     view: VIEWS;
55 const ParticipantsInput = ({
56     className,
57     placeholder,
58     displayBusySlots,
59     organizer,
60     value = [],
61     isOwnedCalendar,
62     onChange,
63     id,
64     addresses,
65     setParticipantError,
66     collapsible = true,
67     onDisplayBusySlots,
68     view,
69 }: Props) => {
70     const [mailSettings] = useMailSettings();
71     const isBusySlotsAvailable = useBusySlotsAvailable(view);
72     const displayAvailabilityUnknown = useCalendarSelector(selectDisplayAvailabilityUnknown) && displayBusySlots;
73     const numberOfAttendees = value.length;
75     const { contactEmails, contactGroups, contactEmailsMap, groupsWithContactsMap } = useContactEmailsCache();
77     const ownNormalizedEmails = useMemo(
78         () => getSelfSendAddresses(addresses).map(({ Email }) => canonicalizeInternalEmail(Email)),
79         [addresses]
80     );
82     const recipients = value.map((attendee) => {
83         return inputToRecipient(attendee.email);
84     });
86     const recipientsSet = new Set(recipients.map(({ Address }) => canonicalizeEmail(Address)));
88     const error = getParticipantsError({
89         isOwnedCalendar,
90         numberOfAttendees,
91         maxAttendees: mailSettings?.RecipientLimit,
92     });
94     const handleAddRecipients = (recipients: Recipient[]) => {
95         setParticipantError?.(false);
96         const normalizedRecipients = recipients.map((recipient) => {
97             const { Address } = recipient;
98             return {
99                 recipient,
100                 normalizedAddress: canonicalizeEmail(Address),
101                 valid: validateEmailAddress(Address),
102             };
103         });
104         const uniqueRecipients = uniqueBy(normalizedRecipients, ({ normalizedAddress }) => normalizedAddress);
105         const newAttendees = uniqueRecipients
106             .filter(({ valid, normalizedAddress }) => {
107                 return valid && !recipientsSet.has(normalizedAddress);
108             })
109             .map(({ recipient }) => {
110                 return emailToAttendee(recipient.Address);
111             });
112         if (!newAttendees.length) {
113             return;
114         }
115         const attendees = newAttendees.reduce<AttendeeModel[]>((acc, cur) => {
116             if (!ownNormalizedEmails.includes(canonicalizeEmail(cur.email, CANONICALIZE_SCHEME.PROTON))) {
117                 acc.push(cur);
118             }
120             return acc;
121         }, []);
122         if (attendees.length) {
123             onChange([...attendees, ...value]);
124         }
125     };
127     const onDelete = (recipient: AttendeeModel) => {
128         onChange(value.filter((item) => recipient !== item));
129     };
131     const toggleIsOptional = ({ email }: AttendeeModel) => {
132         onChange(
133             value.map((attendee) =>
134                 email === attendee.email
135                     ? { ...attendee, role: attendee.role === REQUIRED ? OPTIONAL : REQUIRED }
136                     : attendee
137             )
138         );
139     };
141     const participantRows = (
142         <ParticipantRows
143             attendeeModel={value}
144             contactEmailsMap={contactEmailsMap}
145             isBusySlotsAvailable={isBusySlotsAvailable && displayBusySlots}
146             onDelete={onDelete}
147             toggleIsOptional={toggleIsOptional}
148         />
149     );
151     return (
152         <>
153             <AddressesAutocompleteTwo
154                 hasAddOnBlur
155                 className={className}
156                 placeholder={placeholder}
157                 id={id}
158                 data-testid="participants-input"
159                 contactEmails={contactEmails}
160                 contactGroups={contactGroups}
161                 contactEmailsMap={contactEmailsMap}
162                 groupsWithContactsMap={groupsWithContactsMap}
163                 recipients={recipients}
164                 onAddRecipients={handleAddRecipients}
165                 onAddInvalidEmail={() => {
166                     setParticipantError?.(true);
167                 }}
168                 onChange={(value) => {
169                     if (!value.trimStart()) {
170                         setParticipantError?.(false);
171                     }
172                 }}
173                 validate={(email) => {
174                     if (ownNormalizedEmails.includes(canonicalizeInternalEmail(email))) {
175                         return c('Error').t`Self invitation not allowed`;
176                     }
178                     if (!validateEmailAddress(email)) {
179                         return c('Error').t`Invalid email address`;
180                     }
181                 }}
182             />
183             {error && (
184                 <Alert className="mb-4 mt-2" type="error">
185                     {error}
186                 </Alert>
187             )}
188             {numberOfAttendees > 0 &&
189                 (collapsible ? (
190                     <Details className="border-none mt-1" open>
191                         <Summary>
192                             {c('Event form').ngettext(
193                                 msgid`${numberOfAttendees} participant`,
194                                 `${numberOfAttendees} participants`,
195                                 numberOfAttendees
196                             )}
197                         </Summary>
198                         {participantRows}
199                     </Details>
200                 ) : (
201                     participantRows
202                 ))}
203             {numberOfAttendees > 0 && organizer && <OrganizerRow organizer={organizer} />}
204             {isBusySlotsAvailable && displayAvailabilityUnknown && (
205                 <div
206                     className="flex items-center color-weak mt-2 text-sm bg-weak rounded py-1 px-2"
207                     data-testid="availability-unknown-banner"
208                 >
209                     <Icon name="circle-half-filled" size={2.5} className="rotateZ-45 opacity-70 mr-2" />{' '}
210                     {c('Description').t`Availability unknown`}
211                 </div>
212             )}
213             {isBusySlotsAvailable && !!onDisplayBusySlots && (
214                 <Button
215                     onClick={(e) => {
216                         e.preventDefault();
217                         e.stopPropagation();
218                         onDisplayBusySlots();
219                     }}
220                     shape="underline"
221                     color="norm"
222                     type="button"
223                 >
224                     {c('Action').t`Show busy times`}
225                 </Button>
226             )}
227         </>
228     );
231 export default memo(ParticipantsInput);