1 import { useEffect, useRef, useState } from 'react';
3 import { c } from 'ttag';
5 import { Button } from '@proton/atoms';
6 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
7 import ModalTwo from '@proton/components/components/modalTwo/Modal';
8 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
9 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
10 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
11 import useEventManager from '@proton/components/hooks/useEventManager';
12 import useHandler from '@proton/components/hooks/useHandler';
13 import useNotifications from '@proton/components/hooks/useNotifications';
14 import { useLoading } from '@proton/hooks';
15 import { useContactEmails } from '@proton/mail/contactEmails/hooks';
22 } from '@proton/shared/lib/contacts/properties';
23 import { isContactNameValid, isFirstLastNameValid } from '@proton/shared/lib/contacts/property';
24 import { prepareForEdition } from '@proton/shared/lib/contacts/surgery';
25 import { isMultiValue } from '@proton/shared/lib/contacts/vcard';
26 import { getOtherInformationFields } from '@proton/shared/lib/helpers/contacts';
27 import { canonicalizeEmail, validateEmailAddress } from '@proton/shared/lib/helpers/email';
28 import type { ContactEmailModel } from '@proton/shared/lib/interfaces/contacts/Contact';
29 import type { VCardContact, VCardProperty, VcardNValue } from '@proton/shared/lib/interfaces/contacts/VCard';
30 import type { SimpleMap } from '@proton/shared/lib/interfaces/utils';
31 import isTruthy from '@proton/utils/isTruthy';
32 import randomIntFromInterval from '@proton/utils/randomIntFromInterval';
34 import type { ContactGroupEditProps } from '../group/ContactGroupEditModal';
35 import useApplyGroups from '../hooks/useApplyGroups';
36 import { useSaveVCardContact } from '../hooks/useSaveVCardContact';
37 import type { ContactGroupLimitReachedProps } from '../modals/ContactGroupLimitReachedModal';
38 import type { ContactImageProps } from '../modals/ContactImageModal';
39 import ContactEditProperties from './ContactEditProperties';
40 import ContactEditProperty from './ContactEditProperty';
42 const otherInformationFields = getOtherInformationFields().map(({ value }) => value);
44 export interface ContactEditProps {
46 vCardContact?: VCardContact;
50 export interface ContactEditModalProps {
51 onUpgrade: () => void;
52 onChange?: () => void;
53 onSelectImage: (props: ContactImageProps) => void;
54 onGroupEdit: (props: ContactGroupEditProps) => void;
55 onLimitReached: (props: ContactGroupLimitReachedProps) => void;
58 type Props = ContactEditProps & ContactEditModalProps & ModalProps;
60 const ContactEditModal = ({
62 vCardContact: inputVCardContact = { fn: [] },
71 const { createNotification } = useNotifications();
72 const [loading, withLoading] = useLoading();
73 const { call } = useEventManager();
74 const [isSubmitted, setIsSubmitted] = useState(false);
76 const [vCardContact, setVCardContact] = useState<VCardContact>(prepareForEdition(inputVCardContact));
78 const displayNameFieldRef = useRef<HTMLInputElement>(null);
79 const firstNameFieldRef = useRef<HTMLInputElement>(null);
80 const [contactEmails = [], loadingContactEmails] = useContactEmails();
81 const [modelContactEmails, setModelContactEmails] = useState<SimpleMap<ContactEmailModel>>({});
83 const saveVCardContact = useSaveVCardContact();
84 const { applyGroups, contactGroupLimitReachedModal } = useApplyGroups();
85 const title = contactID ? c('Title').t`Edit contact` : c('Title').t`Create contact`;
87 const displayNameProperty = getSortedProperties(vCardContact, 'fn')[0] as VCardProperty<string>;
88 const nameProperty = getSortedProperties(vCardContact, 'n')[0] as VCardProperty<VcardNValue>;
89 const photoProperty = getSortedProperties(vCardContact, 'photo')[0] as VCardProperty<string>;
91 const getContactEmail = (email: string) =>
92 contactEmails.find((contactEmail) => {
95 contactEmail.ContactID === contactID &&
96 canonicalizeEmail(contactEmail.Email) === canonicalizeEmail(email)
99 // If the contact does not exist before adding to contact group, contactID is not defined, and we have no way of getting it.
100 // If we rely on contactID, adding contact groups would become impossible.
101 // To avoid adding to the wrong contact, check the contact name + the email instead of contactID.
102 // This is still not perfect, because creating a new contact with the same name and same address than one existing
103 // might (depending on the first one found in the list) add the group to the older contact.
104 // That's a super rare case, so I will suggest we live with this "bug".
106 // We also need to trim the value, as form values aren't trimmed until we save the new contact.
107 const { familyNames, givenNames } = nameProperty.value;
108 const fullName = `${givenNames.join(' ').trim()} ${familyNames.join(' ').trim()}`.trim();
109 const displayName = displayNameProperty.value.trim();
112 (fullName === contactEmail.Name || displayName === contactEmail.Name) &&
113 canonicalizeEmail(contactEmail.Email) === canonicalizeEmail(email)
118 if (loadingContactEmails) {
122 const newModelContactEmails = { ...modelContactEmails };
124 const emails = vCardContact.email || [];
125 const displayName = displayNameProperty.value;
126 const givenName = vCardContact?.n?.value.givenNames.join(' ').trim() || '';
127 const familyName = vCardContact?.n?.value.familyNames.join(' ').trim() || '';
128 const computedName = `${givenName} ${familyName}`;
130 // The name can either be the display name, or the computed name if we're creating a new contact
131 const Name = contactID ? displayName : displayName || computedName;
133 emails.forEach((emailProperty) => {
134 const uid = emailProperty.uid;
135 const email = emailProperty.value || '';
137 const existingModel = Object.values(newModelContactEmails).find(
138 (contactEmail) => contactEmail?.uid === uid
142 if (existingModel.Email !== email) {
143 const oldEmail = existingModel.Email;
144 newModelContactEmails[email] = {
149 delete newModelContactEmails[oldEmail];
151 existingModel.Name = Name;
156 const existingContactEmail = getContactEmail(email);
158 if (existingContactEmail) {
159 newModelContactEmails[email] = {
160 ...existingContactEmail,
168 newModelContactEmails[email] = {
172 ContactID: contactID || '',
178 setModelContactEmails(newModelContactEmails);
179 }, [loadingContactEmails, vCardContact.email, vCardContact.n]);
181 // The condition defining if the form is valid is different if we are editing an existing contact or creating a new one
182 // In all cases we want to make sure that all emails are correct
183 const isFormValid = () => {
184 const allEmailsAddress = vCardContact.email?.map((emailProperty) => emailProperty.value).filter(isTruthy) ?? [];
186 // Check if all present address are valid email addresses
187 if (!allEmailsAddress.every((email) => validateEmailAddress(email))) {
191 const displayName = displayNameProperty.value.trim();
193 const givenName = nameProperty.value.givenNames[0].trim();
194 const familyName = nameProperty.value.familyNames[0].trim();
195 const fullName = `${givenName} ${familyName}`;
197 // Check if there is any name present in the contact
198 if (!familyName && !givenName && !displayName) {
202 // Check if the last name is valid
203 if (familyName && !isFirstLastNameValid(familyName)) {
207 // Check if the first name is valid
208 if (givenName && !isFirstLastNameValid(givenName)) {
212 // Check if the display name is valid when editing a contact
213 if ((contactID && displayName && !isContactNameValid(displayName)) || (contactID && !displayName)) {
217 // Check if the full name is valid when creating a contact
218 if ((!contactID && fullName && !isContactNameValid(fullName)) || (!contactID && !fullName)) {
225 const handleRemove = (propertyUID: string) => {
226 setVCardContact((vCardContact) => {
227 return removeVCardProperty(vCardContact, propertyUID);
231 const focusOnField = (uid: string) => {
232 const elm = document.querySelector(`[data-contact-property-id="${uid}"]`) as HTMLElement;
234 // Try to focus on the input field, if not present try the textarea
235 const hasInput = elm?.querySelector('input');
241 elm?.querySelector('textarea')?.focus();
244 const handleAdd = (inputField?: string) => () => {
245 let field = inputField;
248 // Get random field from other info, but not a limited one
249 const properties = getVCardProperties(vCardContact);
250 const filteredOtherInformationFields = otherInformationFields.filter(
251 (field) => isMultiValue(field) || !properties.find((property) => property.field === field)
254 const index = randomIntFromInterval(0, filteredOtherInformationFields.length - 1);
256 field = filteredOtherInformationFields[index];
259 setVCardContact((vCardContact) => {
260 const { newVCardContact, newVCardProperty } = addVCardProperty(vCardContact, { field } as VCardProperty);
261 setTimeout(() => focusOnField(newVCardProperty.uid));
262 return newVCardContact;
266 const saveContactGroups = useHandler(async () => {
268 Object.values(modelContactEmails).map(async (modelContactEmail) => {
269 if (modelContactEmail) {
270 const contactEmail = getContactEmail(modelContactEmail.Email);
272 await applyGroups([contactEmail], modelContactEmail.changes, true);
279 const handleSubmit = async () => {
280 setIsSubmitted(true);
281 if (!isFormValid()) {
282 firstNameFieldRef.current?.focus();
287 await saveVCardContact(contactID, vCardContact);
289 await saveContactGroups();
291 createNotification({ text: c('Success').t`Contact saved` });
297 const handleChangeVCard = (property: VCardProperty) => {
298 setVCardContact((vCardContact) => {
299 return updateVCardContact(vCardContact, property);
303 const handleContactEmailChange = (contactEmail: ContactEmailModel) =>
304 setModelContactEmails((modelContactEmails) => ({ ...modelContactEmails, [contactEmail.Email]: contactEmail }));
308 handleAdd(newField)();
312 // Default focus on name field
314 firstNameFieldRef.current?.focus();
319 <ModalTwo size="large" className="contacts-modal" {...rest}>
320 <ModalTwoHeader title={title} />
322 <div className="mb-4">
324 ref={firstNameFieldRef}
325 vCardContact={vCardContact}
326 isSubmitted={isSubmitted}
327 onRemove={handleRemove}
329 vCardProperty={nameProperty}
330 onChangeVCard={handleChangeVCard}
331 onUpgrade={onUpgrade}
332 onSelectImage={onSelectImage}
333 onGroupEdit={onGroupEdit}
336 ref={displayNameFieldRef}
337 vCardContact={vCardContact}
338 isSubmitted={isSubmitted}
339 onRemove={handleRemove}
341 vCardProperty={displayNameProperty}
342 onChangeVCard={handleChangeVCard}
343 onUpgrade={onUpgrade}
344 onSelectImage={onSelectImage}
345 onGroupEdit={onGroupEdit}
349 vCardContact={vCardContact}
350 isSubmitted={isSubmitted}
351 onRemove={handleRemove}
354 vCardProperty={photoProperty}
355 onChangeVCard={handleChangeVCard}
356 onUpgrade={onUpgrade}
357 onSelectImage={onSelectImage}
358 onGroupEdit={onGroupEdit}
361 <ContactEditProperties
364 isSubmitted={isSubmitted}
365 onRemove={handleRemove}
366 vCardContact={vCardContact}
367 onChangeVCard={handleChangeVCard}
368 onUpgrade={onUpgrade}
369 onSelectImage={onSelectImage}
370 onGroupEdit={onGroupEdit}
372 <ContactEditProperties
375 isSubmitted={isSubmitted}
376 onRemove={handleRemove}
378 onAdd={handleAdd('email')}
379 contactEmails={modelContactEmails}
380 onContactEmailChange={handleContactEmailChange}
381 vCardContact={vCardContact}
382 onChangeVCard={handleChangeVCard}
383 onUpgrade={onUpgrade}
384 onSelectImage={onSelectImage}
385 onGroupEdit={onGroupEdit}
386 onLimitReached={onLimitReached}
388 {['tel', 'adr', 'bday', 'note'].map((item) => (
389 <ContactEditProperties
393 isSubmitted={isSubmitted}
394 onRemove={handleRemove}
396 onAdd={handleAdd(item)}
397 vCardContact={vCardContact}
398 onChangeVCard={handleChangeVCard}
399 onUpgrade={onUpgrade}
400 onSelectImage={onSelectImage}
401 onGroupEdit={onGroupEdit}
404 <ContactEditProperties
405 isSubmitted={isSubmitted}
407 onRemove={handleRemove}
409 vCardContact={vCardContact}
410 onChangeVCard={handleChangeVCard}
411 onUpgrade={onUpgrade}
412 onSelectImage={onSelectImage}
413 onGroupEdit={onGroupEdit}
417 <Button onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
421 data-testid="create-contact:save"
422 onClick={() => withLoading(handleSubmit())}
424 {c('Action').t`Save`}
428 {contactGroupLimitReachedModal}
433 export default ContactEditModal;