Remove payments API routing initialization
[ProtonMail-WebClient.git] / packages / components / containers / contacts / widget / ContactsWidgetContainer.tsx
blobc5a4d8586b3bd628524b3d66982abd8746354937
1 import type { RefObject } from 'react';
2 import { useMemo, useState } from 'react';
4 import { c, msgid } from 'ttag';
6 import { useUser } from '@proton/account/user/hooks';
7 import { useGetUserKeys } from '@proton/account/userKeys/hooks';
8 import { CircleLoader } from '@proton/atoms';
9 import SearchInput from '@proton/components/components/input/SearchInput';
10 import useApi from '@proton/components/hooks/useApi';
11 import useNotifications from '@proton/components/hooks/useNotifications';
12 import { useMailSettings } from '@proton/mail/mailSettings/hooks';
13 import { exportContacts } from '@proton/shared/lib/contacts/helpers/export';
14 import { extractMergeable } from '@proton/shared/lib/contacts/helpers/merge';
15 import type { Recipient } from '@proton/shared/lib/interfaces';
16 import type { ContactEmail } from '@proton/shared/lib/interfaces/contacts';
17 import { ATTACHMENT_MAX_COUNT } from '@proton/shared/lib/mail/constants';
18 import { DEFAULT_MAILSETTINGS } from '@proton/shared/lib/mail/mailSettings';
19 import clsx from '@proton/utils/clsx';
21 import type { ContactEditProps } from '../edit/ContactEditModal';
22 import type { ContactGroupEditProps } from '../group/ContactGroupEditModal';
23 import useContactList from '../hooks/useContactList';
24 import ContactsList from '../lists/ContactsList';
25 import type { ContactMergeProps } from '../merge/ContactMergeModal';
26 import type { ContactDeleteProps } from '../modals/ContactDeleteModal';
27 import type { ContactGroupLimitReachedProps } from '../modals/ContactGroupLimitReachedModal';
28 import type { SelectEmailsProps } from '../modals/SelectEmailsModal';
29 import MergeContactBanner from '../widget/MergeContactBanner';
30 import ContactsWidgetPlaceholder, { EmptyType } from './ContactsWidgetPlaceholder';
31 import ContactsWidgetToolbar from './ContactsWidgetToolbar';
32 import type { CustomAction } from './types';
34 import './ContactsWidget.scss';
36 interface Props {
37     onClose?: () => void;
38     onCompose?: (recipients: Recipient[], attachments: File[]) => void;
39     onLock?: (lock: boolean) => void;
40     customActions: CustomAction[];
41     onDetails: (contactID: string) => void;
42     onEdit: (props: ContactEditProps) => void;
43     onDelete: (props: ContactDeleteProps) => void;
44     onImport: () => void;
45     onMerge: (props: ContactMergeProps) => void;
46     onGroupDetails: (contactGroupID: string) => void;
47     onGroupEdit: (props: ContactGroupEditProps) => void;
48     onLimitReached: (props: ContactGroupLimitReachedProps) => void;
49     onUpgrade: () => void;
50     onSelectEmails: (props: SelectEmailsProps) => Promise<ContactEmail[]>;
51     isDrawer?: boolean;
52     searchInputRef?: RefObject<HTMLInputElement>;
55 const ContactsWidgetContainer = ({
56     onClose,
57     onCompose,
58     onLock,
59     customActions,
60     onDetails,
61     onEdit,
62     onDelete,
63     onImport,
64     onMerge,
65     onGroupDetails,
66     onGroupEdit,
67     onLimitReached,
68     onUpgrade,
69     onSelectEmails,
70     isDrawer = false,
71     searchInputRef,
72 }: Props) => {
73     const [mailSettings] = useMailSettings();
74     const [user, loadingUser] = useUser();
75     const getUserKeys = useGetUserKeys();
76     const { createNotification } = useNotifications();
77     const api = useApi();
79     const [search, setSearch] = useState('');
81     const contactList = useContactList({
82         search,
83     });
85     const {
86         formattedContacts,
87         checkedIDs,
88         contacts,
89         contactGroupsMap,
90         handleCheck,
91         handleCheckOne,
92         contactEmailsMap,
93         selectedIDs,
94         handleCheckAll,
95         filteredContacts,
96         hasCheckedAllFiltered,
97         loading: loadingContacts,
98     } = contactList;
100     const mergeableContacts = useMemo(() => extractMergeable(formattedContacts), [formattedContacts]);
101     const countMergeableContacts = mergeableContacts.reduce(
102         (acc, mergeableContact) => acc + mergeableContact.length,
103         0
104     );
106     const noEmailsContactIDs = selectedIDs.filter((contactID) => !contactEmailsMap[contactID]?.length);
108     const handleClearSearch = () => {
109         // If done synchronously, button is removed from the dom and the dropdown considers a click outside
110         setTimeout(() => setSearch(''));
111     };
113     const handleCompose = () => {
114         const maxContacts = mailSettings?.RecipientLimit || DEFAULT_MAILSETTINGS.RecipientLimit;
116         if (selectedIDs.length > maxContacts) {
117             createNotification({
118                 type: 'error',
119                 text: c('Error').ngettext(
120                     msgid`You can't send a mail to more than ${maxContacts} recipient`,
121                     `You can't send a mail to more than ${maxContacts} recipients`,
122                     maxContacts
123                 ),
124             });
125             return;
126         }
128         const contactWithEmailIDs = selectedIDs.filter((contactID) => contactEmailsMap[contactID]?.length);
130         if (noEmailsContactIDs.length) {
131             const noEmailsContactNames = noEmailsContactIDs.map(
132                 // Looping in all contacts is no really performant but should happen rarely
133                 (contactID) => contacts.find((contact) => contact.ID === contactID)?.Name
134             );
136             const noEmailsContactNamesCount = noEmailsContactNames.length;
137             const noEmailsContactNamesList = noEmailsContactNames.join(', ');
139             const text = c('Error').ngettext(
140                 msgid`One of the contacts has no email address: ${noEmailsContactNamesList}`,
141                 `Some contacts have no email addresses: ${noEmailsContactNamesList} `,
142                 noEmailsContactNamesCount
143             );
145             createNotification({ type: 'warning', text });
146         }
148         const contactEmailsOfContacts = contactWithEmailIDs.map(
149             (contactID) => contactEmailsMap[contactID]
150         ) as ContactEmail[][];
151         const recipients = contactEmailsOfContacts.map((contactEmails) => {
152             const contactEmail = contactEmails[0];
153             return { Name: contactEmail.Name, Address: contactEmail.Email };
154         });
156         onCompose?.(recipients, []);
157         onClose?.();
158     };
160     const handleForward = async () => {
161         // We cannot attach more than 100 files to a message
162         const maxAttachments = ATTACHMENT_MAX_COUNT;
163         if (selectedIDs.length > maxAttachments) {
164             createNotification({
165                 type: 'error',
166                 text: c('Action').ngettext(
167                     msgid`You can't send vCard files of more than ${maxAttachments} contacts`,
168                     `You can't send vCard files of more than ${maxAttachments} contacts`,
169                     maxAttachments
170                 ),
171             });
172             return;
173         }
175         try {
176             const userKeys = await getUserKeys();
177             const exportedContacts = await exportContacts(selectedIDs, userKeys, api);
179             const files = exportedContacts.map(
180                 ({ name, vcard }) => new File([vcard], name, { type: 'text/plain;charset=utf-8' })
181             );
183             onCompose?.([], files);
184         } catch {
185             createNotification({
186                 type: 'error',
187                 text: c('Error').t`There was an error when exporting the contacts vCards`,
188             });
189         }
190         onClose?.();
191     };
193     const handleDelete = () => {
194         const deleteAll = selectedIDs.length === contacts.length;
195         onDelete({
196             contactIDs: selectedIDs,
197             deleteAll,
198             onDelete: () => {
199                 if (selectedIDs.length === filteredContacts.length) {
200                     setSearch('');
201                 }
202                 handleCheckAll(false);
203             },
204         });
205         onClose?.();
206     };
208     const handleCreate = () => {
209         onEdit({});
210         onClose?.();
211     };
213     const handleImport = () => {
214         onImport();
215         onClose?.();
216     };
218     const handleMerge = (mergeContactsDetected?: boolean) => {
219         const selectedContacts = formattedContacts.filter((contact) => selectedIDs.includes(contact.ID));
220         const contacts = mergeContactsDetected ? mergeableContacts : [selectedContacts];
222         const onMerged = () => handleCheckAll(false);
223         onMerge({ contacts, onMerged });
224         onClose?.();
225     };
227     const contactsCount = formattedContacts.length;
228     const contactsLength = contacts ? contacts.length : 0;
229     const selectedContactsLength = selectedIDs.length;
231     const loading = loadingContacts || loadingUser;
232     const showPlaceholder = !loading && !contactsCount;
233     const showList = !loading && !showPlaceholder;
235     return (
236         <div className="flex flex-column flex-nowrap h-full">
237             <div className="contacts-widget-search-container shrink-0">
238                 <label htmlFor="id_contact-widget-search" className="sr-only">{c('Placeholder')
239                     .t`Search for name or email`}</label>
240                 <SearchInput
241                     ref={searchInputRef}
242                     autoFocus={!isDrawer}
243                     value={search}
244                     onChange={setSearch}
245                     id="id_contact-widget-search"
246                     placeholder={c('Placeholder').t`Name or email address`}
247                 />
248                 <span className="sr-only" aria-atomic aria-live="assertive">
249                     {c('Info').ngettext(
250                         msgid`${contactsCount} contact found`,
251                         `${contactsCount} contacts found`,
252                         contactsCount
253                     )}
254                 </span>
255             </div>
256             <div className="contacts-widget-toolbar py-3 border-bottom border-weak shrink-0">
257                 <ContactsWidgetToolbar
258                     allChecked={hasCheckedAllFiltered}
259                     selected={selectedIDs}
260                     noEmailsContactCount={noEmailsContactIDs.length}
261                     onCheckAll={handleCheckAll}
262                     onCompose={onCompose ? handleCompose : undefined}
263                     customActions={customActions}
264                     contactList={contactList}
265                     onForward={handleForward}
266                     onCreate={handleCreate}
267                     onDelete={handleDelete}
268                     onMerge={() => handleMerge(false)}
269                     onLimitReached={onLimitReached}
270                     onClose={onClose}
271                     onLock={onLock}
272                     onGroupEdit={onGroupEdit}
273                     onUpgrade={onUpgrade}
274                     onSelectEmails={onSelectEmails}
275                     isDrawer={isDrawer}
276                 />
278                 {contactsLength ? (
279                     <p
280                         className={clsx(
281                             'text-sm font-semibold m-0 pt-3',
282                             selectedContactsLength ? 'color-weak' : 'color-hint'
283                         )}
284                     >
285                         {selectedContactsLength
286                             ? // translator: Number of selected contacts out of total number of contacts, e.g. "1/10 contacts selected"
287                               c('Info').ngettext(
288                                   msgid`${selectedContactsLength}/${contactsLength} contact selected`,
289                                   `${selectedContactsLength}/${contactsLength} contacts selected`,
290                                   selectedContactsLength
291                               )
292                             : // translator: Total number of contact when none are selected, e.g. "10 contacts"
293                               c('Info').ngettext(
294                                   msgid`${contactsLength} contact`,
295                                   `${contactsLength} contacts`,
296                                   contactsLength
297                               )}
298                     </p>
299                 ) : null}
300             </div>
301             {showList && countMergeableContacts ? (
302                 <MergeContactBanner onMerge={() => handleMerge(true)} countMergeableContacts={countMergeableContacts} />
303             ) : null}
304             <div className="flex-1 w-full">
305                 {loading ? (
306                     <div className="flex h-full">
307                         <CircleLoader className="m-auto color-primary" size="large" />
308                     </div>
309                 ) : null}
310                 {showPlaceholder ? (
311                     <ContactsWidgetPlaceholder
312                         type={contactsLength ? EmptyType.Search : EmptyType.All}
313                         onClearSearch={handleClearSearch}
314                         onCreate={handleCreate}
315                         onImport={handleImport}
316                     />
317                 ) : null}
318                 {showList ? (
319                     <ContactsList
320                         contacts={formattedContacts}
321                         contactGroupsMap={contactGroupsMap}
322                         user={user}
323                         onCheckOne={handleCheckOne}
324                         isLargeViewport={false}
325                         checkedIDs={checkedIDs}
326                         onCheck={handleCheck}
327                         onClick={onDetails}
328                         activateDrag={false}
329                         onGroupDetails={onGroupDetails}
330                         isDrawer={isDrawer}
331                         onCompose={onCompose}
332                     />
333                 ) : null}
334             </div>
335         </div>
336     );
339 export default ContactsWidgetContainer;