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';
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;
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[]>;
52 searchInputRef?: RefObject<HTMLInputElement>;
55 const ContactsWidgetContainer = ({
73 const [mailSettings] = useMailSettings();
74 const [user, loadingUser] = useUser();
75 const getUserKeys = useGetUserKeys();
76 const { createNotification } = useNotifications();
79 const [search, setSearch] = useState('');
81 const contactList = useContactList({
96 hasCheckedAllFiltered,
97 loading: loadingContacts,
100 const mergeableContacts = useMemo(() => extractMergeable(formattedContacts), [formattedContacts]);
101 const countMergeableContacts = mergeableContacts.reduce(
102 (acc, mergeableContact) => acc + mergeableContact.length,
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(''));
113 const handleCompose = () => {
114 const maxContacts = mailSettings?.RecipientLimit || DEFAULT_MAILSETTINGS.RecipientLimit;
116 if (selectedIDs.length > maxContacts) {
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`,
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
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
145 createNotification({ type: 'warning', text });
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 };
156 onCompose?.(recipients, []);
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) {
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`,
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' })
183 onCompose?.([], files);
187 text: c('Error').t`There was an error when exporting the contacts vCards`,
193 const handleDelete = () => {
194 const deleteAll = selectedIDs.length === contacts.length;
196 contactIDs: selectedIDs,
199 if (selectedIDs.length === filteredContacts.length) {
202 handleCheckAll(false);
208 const handleCreate = () => {
213 const handleImport = () => {
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 });
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;
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>
242 autoFocus={!isDrawer}
245 id="id_contact-widget-search"
246 placeholder={c('Placeholder').t`Name or email address`}
248 <span className="sr-only" aria-atomic aria-live="assertive">
250 msgid`${contactsCount} contact found`,
251 `${contactsCount} contacts found`,
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}
272 onGroupEdit={onGroupEdit}
273 onUpgrade={onUpgrade}
274 onSelectEmails={onSelectEmails}
281 'text-sm font-semibold m-0 pt-3',
282 selectedContactsLength ? 'color-weak' : 'color-hint'
285 {selectedContactsLength
286 ? // translator: Number of selected contacts out of total number of contacts, e.g. "1/10 contacts selected"
288 msgid`${selectedContactsLength}/${contactsLength} contact selected`,
289 `${selectedContactsLength}/${contactsLength} contacts selected`,
290 selectedContactsLength
292 : // translator: Total number of contact when none are selected, e.g. "10 contacts"
294 msgid`${contactsLength} contact`,
295 `${contactsLength} contacts`,
301 {showList && countMergeableContacts ? (
302 <MergeContactBanner onMerge={() => handleMerge(true)} countMergeableContacts={countMergeableContacts} />
304 <div className="flex-1 w-full">
306 <div className="flex h-full">
307 <CircleLoader className="m-auto color-primary" size="large" />
311 <ContactsWidgetPlaceholder
312 type={contactsLength ? EmptyType.Search : EmptyType.All}
313 onClearSearch={handleClearSearch}
314 onCreate={handleCreate}
315 onImport={handleImport}
320 contacts={formattedContacts}
321 contactGroupsMap={contactGroupsMap}
323 onCheckOne={handleCheckOne}
324 isLargeViewport={false}
325 checkedIDs={checkedIDs}
326 onCheck={handleCheck}
329 onGroupDetails={onGroupDetails}
331 onCompose={onCompose}
339 export default ContactsWidgetContainer;