1 import type { ChangeEvent, FormEvent } from 'react';
2 import { useEffect, useMemo, useRef, useState } from 'react';
4 import { c, msgid } from 'ttag';
6 import { useUserSettings } from '@proton/account/userSettings/hooks';
7 import { Button } from '@proton/atoms';
8 import Form from '@proton/components/components/form/Form';
9 import Checkbox from '@proton/components/components/input/Checkbox';
10 import SearchInput from '@proton/components/components/input/SearchInput';
11 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
12 import ModalTwo from '@proton/components/components/modalTwo/Modal';
13 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
14 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
15 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
16 import Option from '@proton/components/components/option/Option';
17 import SelectTwo from '@proton/components/components/selectTwo/SelectTwo';
18 import useActiveBreakpoint from '@proton/components/hooks/useActiveBreakpoint';
19 import useContactEmailsSortedByName from '@proton/components/hooks/useContactEmailsSortedByName';
20 import { useContactGroups } from '@proton/mail';
21 import { toMap } from '@proton/shared/lib/helpers/object';
22 import { normalize } from '@proton/shared/lib/helpers/string';
23 import type { Recipient } from '@proton/shared/lib/interfaces/Address';
24 import type { ContactEmail, ContactGroup } from '@proton/shared/lib/interfaces/contacts/Contact';
25 import clsx from '@proton/utils/clsx';
27 import type { ContactEditProps } from '../edit/ContactEditModal';
28 import ContactSelectorEmptyContacts from './ContactSelectorEmptyContacts';
29 import ContactSelectorEmptyResults from './ContactSelectorEmptyResults';
30 import ContactSelectorList from './ContactSelectorList';
31 import ContactSelectorRow from './ContactSelectorRow';
33 import './ContactSelectorModal.scss';
35 const convertContactToRecipient = ({ Name, ContactID, Email }: ContactEmail) => ({
41 export interface ContactSelectorProps {
43 onGroupDetails: (contactGroupID: string) => void;
44 onEdit: (props: ContactEditProps) => void;
47 interface ContactSelectorResolver {
48 onResolve: (recipients: Recipient[]) => void;
52 const allContactsGroup = (): Pick<ContactGroup, 'Name' | 'ID'> => ({
54 Name: c('Label').t`All contacts`,
57 type Props = ContactSelectorProps & ContactSelectorResolver & ModalProps;
59 const ContactSelectorModal = ({ onResolve, onReject, inputValue, onGroupDetails, onEdit, ...rest }: Props) => {
60 const { viewportWidth } = useActiveBreakpoint();
62 const searchInputRef = useRef<HTMLInputElement>(null);
63 const [contactEmails, loadingContactEmails] = useContactEmailsSortedByName();
64 const [userSettings, loadingUserSettings] = useUserSettings();
65 const [contactGroups = [], loadingContactGroups] = useContactGroups();
66 const [selectedGroup, setSelectedGroup] = useState<string>(allContactsGroup().ID);
68 const contactGroupsWithDefault = [allContactsGroup(), ...contactGroups];
70 const emailsFromInput = inputValue.map((e: any) => e.Address);
71 const contactGroupsMap = toMap(contactGroups);
73 const initialCheckedContactEmailsMap = contactEmails.reduce(
74 (acc: { [key: string]: boolean }, contactEmail: ContactEmail) => {
75 acc[contactEmail.ID] = emailsFromInput.includes(contactEmail.Email);
81 const [searchValue, setSearchValue] = useState('');
82 const [lastCheckedID, setLastCheckedID] = useState('');
83 const [isAllChecked, setIsAllChecked] = useState(false);
85 const [filteredContactEmails, setFilteredContactEmails] = useState(contactEmails);
86 const [checkedContactEmailMap, setCheckedContactEmailMap] = useState<{ [key: string]: boolean }>(
87 initialCheckedContactEmailsMap
89 const [checkedContactEmails, setCheckedContactEmails] = useState<ContactEmail[]>([]);
90 const totalChecked = checkedContactEmails.length;
92 const loading = loadingContactEmails || loadingUserSettings || loadingContactGroups;
94 const toggleCheckAll = (checked: boolean) => {
95 const update = filteredContactEmails.reduce((acc: { [key: string]: boolean }, contactEmail: ContactEmail) => {
96 acc[contactEmail.ID] = checked;
98 }, Object.create(null));
100 setCheckedContactEmailMap({ ...checkedContactEmailMap, ...update });
103 const onCheck = (checkedIDs: string[] = [], checked = false) => {
104 const update = checkedIDs.reduce((acc, checkedID) => {
105 acc[checkedID] = checked;
107 }, Object.create(null));
109 setCheckedContactEmailMap({ ...checkedContactEmailMap, ...update });
112 const handleCheckAll = (e: ChangeEvent<HTMLInputElement>) => toggleCheckAll(e.target.checked);
114 const handleCheck = (e: ChangeEvent<HTMLInputElement>, checkedID: string) => {
119 target: EventTarget & HTMLInputElement;
120 nativeEvent: Event & { shiftKey?: boolean };
122 const checkedIDs = checkedID ? [checkedID] : [];
124 if (lastCheckedID && nativeEvent.shiftKey) {
125 const start = filteredContactEmails.findIndex((c: ContactEmail) => c.ID === checkedID);
126 const end = filteredContactEmails.findIndex((c: ContactEmail) => c.ID === lastCheckedID);
128 ...filteredContactEmails
129 .slice(Math.min(start, end), Math.max(start, end) + 1)
130 .map((c: ContactEmail) => c.ID)
135 setLastCheckedID(checkedID);
136 onCheck(checkedIDs, target.checked);
140 const handleClearSearch = () => {
142 searchInputRef?.current?.focus();
145 const searchFilter = (c: ContactEmail) => {
146 const tokenizedQuery = normalize(searchValue, true).split(' ');
148 const groupNameTokens = c.LabelIDs.reduce((acc: string[], labelId) => {
149 const tokenized = normalize(contactGroupsMap[labelId].Name, true).split(' ');
150 return [...acc, ...tokenized];
154 tokenizedQuery.some((token) => normalize(c.Name, true).includes(token)) ||
155 tokenizedQuery.some((token) => normalize(c.Email, true).includes(token)) ||
156 tokenizedQuery.some((token) => groupNameTokens.some((g) => g.includes(token)))
160 const filterContactsByGroup = useMemo(() => {
161 const filteredContacts = contactEmails;
162 if (selectedGroup === allContactsGroup().ID) {
163 return filteredContacts;
166 return filteredContacts.filter((contact: ContactEmail) => contact.LabelIDs.includes(selectedGroup));
170 searchInputRef?.current?.focus();
174 setLastCheckedID('');
175 setFilteredContactEmails(filterContactsByGroup.filter(searchFilter));
176 }, [searchValue, selectedGroup]);
179 setCheckedContactEmails(contactEmails.filter((c: ContactEmail) => !!checkedContactEmailMap[c.ID]));
180 }, [checkedContactEmailMap]);
184 !!filteredContactEmails.length &&
185 filteredContactEmails.every((c: ContactEmail) => !!checkedContactEmailMap[c.ID])
187 }, [filteredContactEmails, checkedContactEmailMap]);
189 const handleSearchValue = (value: string) => setSearchValue(value);
191 const handleSubmit = (event: FormEvent) => {
192 event.stopPropagation();
193 event.preventDefault();
195 onResolve(checkedContactEmails.map(convertContactToRecipient));
201 ? c('Action').t`Insert contact`
202 : c('Action').ngettext(
203 msgid`Insert ${totalChecked} contact`,
204 `Insert ${totalChecked} contacts`,
209 <ModalTwo size="large" as={Form} onSubmit={handleSubmit} data-testid="modal:contactlist" {...rest}>
210 <ModalTwoHeader title={c('Title').t`Insert contacts`} />
212 {!contactEmails.length ? (
213 <ContactSelectorEmptyContacts onClose={rest.onClose} onEdit={onEdit} />
217 className={clsx(['mb-2 flex flex-nowrap gap-4', viewportWidth['<=small'] && 'flex-column'])}
219 <div className="grow-2">
223 onChange={handleSearchValue}
224 placeholder={c('Placeholder').t`Search name, email or group`}
227 <div className={clsx([!viewportWidth['<=small'] && 'w-1/3'])}>
229 onChange={({ value }) => setSelectedGroup(value)}
230 value={selectedGroup}
231 disabled={loadingContactGroups}
233 {contactGroupsWithDefault.map((group) => (
234 <Option key={group.ID} value={group.ID} title={group.Name}>
241 {filteredContactEmails.length ? (
243 {!viewportWidth['<=small'] && (
244 <div className="flex flex-nowrap flex-1 contact-list-row p-4">
247 className="w-full h-full"
248 checked={isAllChecked}
249 onChange={handleCheckAll}
252 <div className="flex flex-1 self-center">
253 <div className="w-custom pl-4" style={{ '--w-custom': '45%' }}>
254 <strong className="text-uppercase">{c('Label').t`Name`}</strong>
256 <div className="flex-1">
257 <strong className="text-uppercase">{c('Label').t`Email`}</strong>
263 rowCount={filteredContactEmails.length}
264 userSettings={userSettings}
265 className={clsx([viewportWidth['<=small'] && 'mt-4'])}
266 rowRenderer={({ index, style }) => (
268 onCheck={handleCheck}
270 key={filteredContactEmails[index].ID}
271 contact={filteredContactEmails[index]}
272 checked={!!checkedContactEmailMap[filteredContactEmails[index].ID]}
273 isSmallViewport={viewportWidth['<=small']}
279 <ContactSelectorEmptyResults onClearSearch={handleClearSearch} query={searchValue} />
285 <Button type="button" onClick={rest.onClose} disabled={loading}>
286 {c('Action').t`Cancel`}
288 {contactEmails.length ? (
293 disabled={!totalChecked}
294 data-testid="modal:contactlist:submit"
304 export default ContactSelectorModal;