1 import { useEffect, useMemo, useState } from 'react';
3 import { c, msgid } from 'ttag';
5 import { useUserKeys } from '@proton/account/userKeys/hooks';
6 import { Button } from '@proton/atoms';
7 import Alert from '@proton/components/components/alert/Alert';
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 DynamicProgress from '@proton/components/components/progress/DynamicProgress';
12 import useApi from '@proton/components/hooks/useApi';
13 import { useLoading } from '@proton/hooks';
14 import { addContacts, deleteContacts, getContact } from '@proton/shared/lib/api/contacts';
15 import { getApiWithAbort } from '@proton/shared/lib/api/helpers/customConfig';
16 import { processApiRequestsSafe } from '@proton/shared/lib/api/helpers/safeApiRequests';
17 import { API_CODES } from '@proton/shared/lib/constants';
18 import { ADD_CONTACTS_MAX_SIZE, API_SAFE_INTERVAL, CATEGORIES, OVERWRITE } from '@proton/shared/lib/contacts/constants';
19 import { prepareVCardContact as decrypt } from '@proton/shared/lib/contacts/decrypt';
20 import { prepareVCardContact as encrypt } from '@proton/shared/lib/contacts/encrypt';
21 import { splitEncryptedContacts } from '@proton/shared/lib/contacts/helpers/import';
22 import { merge } from '@proton/shared/lib/contacts/helpers/merge';
23 import { combineProgress } from '@proton/shared/lib/contacts/helpers/progress';
24 import { wait } from '@proton/shared/lib/helpers/promise';
25 import type { Contact as ContactType, SimpleEncryptedContact } from '@proton/shared/lib/interfaces/contacts';
26 import type { VCardContact } from '@proton/shared/lib/interfaces/contacts/VCard';
27 import { splitKeys } from '@proton/shared/lib/keys/keys';
28 import chunk from '@proton/utils/chunk';
30 const { OVERWRITE_CONTACT } = OVERWRITE;
31 const { INCLUDE, IGNORE } = CATEGORIES;
32 const { SINGLE_SUCCESS } = API_CODES;
34 type Signal = { signal: AbortSignal };
37 alreadyMerged?: VCardContact;
38 mergeFinished: boolean;
40 onMerged?: () => void;
42 beMergedModel: { [ID: string]: string[] };
43 beDeletedModel: { [ID: string]: string };
44 totalBeMerged: number;
45 totalBeDeleted: number;
48 const ContactMergingContent = ({
60 const [userKeysList] = useUserKeys();
61 const { privateKeys, publicKeys } = useMemo(() => splitKeys(userKeysList), [userKeysList]);
63 const [loading, withLoading] = useLoading(true);
64 const [model, setModel] = useState<{
65 mergedAndEncrypted: string[];
66 failedOnMergeAndEncrypt: string[];
68 failedOnSubmit: string[];
71 mergedAndEncrypted: [],
72 failedOnMergeAndEncrypt: [],
78 const isDeleteOnly = totalBeMerged <= 0 && totalBeDeleted > 0;
81 // Prepare api for allowing cancellation in the middle of the merge
82 const abortController = new AbortController();
83 const apiWithAbort = getApiWithAbort(api, abortController.signal);
86 * Get a contact from its ID and decrypt it. Return contact as a list of properties
88 const getDecryptedContact = async (ID: string, { signal }: Signal): Promise<VCardContact> => {
92 const { Contact } = await apiWithAbort<{ Contact: ContactType }>(getContact(ID));
93 const { vCardContact, errors: contactErrors } = await decrypt(Contact, {
97 if (contactErrors.length) {
98 throw new Error(`Error decrypting contact ${ID}`);
104 * Get and decrypt a group of contacts to be merged. Return array of decrypted contacts
106 const getDecryptedGroup = (groupIDs: string[] = [], { signal }: Signal) => {
107 return processApiRequestsSafe(
108 groupIDs.map((ID) => () => getDecryptedContact(ID, { signal })),
115 * Encrypt a contact already merged. Useful for the case of `preview merge`
117 const encryptAlreadyMerged = async ({ signal }: Signal) => {
118 if (signal.aborted) {
121 // beMergedModel only contains one entry in this case
122 const [[beMergedID, groupIDs]] = Object.entries(beMergedModel);
123 const beSubmittedContacts: SimpleEncryptedContact[] = [];
124 if (!alreadyMerged) {
125 throw new Error('Contact already merged is undefined');
128 const encryptedMergedContact = await encrypt(alreadyMerged, {
129 privateKey: privateKeys[0],
130 publicKey: publicKeys[0],
132 beSubmittedContacts.push({ contact: encryptedMergedContact, contactId: beMergedID });
134 if (!signal.aborted) {
135 setModel((model) => ({ ...model, mergedAndEncrypted: [...model.mergedAndEncrypted, ...groupIDs] }));
138 if (!signal.aborted) {
139 setModel((model) => ({
141 failedOnMergeAndEncrypt: [...model.failedOnMergeAndEncrypt, ...groupIDs],
145 return beSubmittedContacts;
149 * Merge groups of contacts characterized by their ID. Return the encrypted merged contacts
150 * to be submitted plus the IDs of the contacts to be deleted after the merge
152 const mergeAndEncrypt = async ({ signal }: Signal) => {
153 const beSubmittedContacts: SimpleEncryptedContact[] = [];
154 for (const [beMergedID, groupIDs] of Object.entries(beMergedModel)) {
155 if (signal.aborted) {
159 const decryptedGroup = await getDecryptedGroup(groupIDs, { signal });
160 const encryptedMergedContact = await encrypt(merge(decryptedGroup), {
161 privateKey: privateKeys[0],
162 publicKey: publicKeys[0],
164 beSubmittedContacts.push({ contact: encryptedMergedContact, contactId: beMergedID });
165 if (!signal.aborted) {
166 setModel((model) => ({
168 mergedAndEncrypted: [...model.mergedAndEncrypted, ...groupIDs],
172 if (!signal.aborted) {
173 setModel((model) => ({
175 failedOnMergeAndEncrypt: [...model.failedOnMergeAndEncrypt, ...groupIDs],
180 return beSubmittedContacts;
184 * Submit a batch of merged contacts to the API
186 const submitBatch = async (
187 { contacts = [], labels }: { contacts: SimpleEncryptedContact[]; labels: number },
190 if (signal.aborted || !contacts.length) {
193 const beDeletedBatchIDs = [];
195 await apiWithAbort<{ Responses: { Response: { Code: number; Contact?: ContactType } }[] }>(
197 Contacts: contacts.map(({ contact }) => contact),
198 Overwrite: OVERWRITE_CONTACT,
202 ).Responses.map(({ Response }: any) => Response);
204 if (signal.aborted) {
208 for (const [index, { Code }] of responses.entries()) {
209 const ID = contacts[index].contactId;
210 const groupIDs = beMergedModel[ID];
211 const beDeletedAfterMergeIDs = groupIDs.slice(1);
212 if (Code === SINGLE_SUCCESS) {
213 if (!signal.aborted) {
214 setModel((model) => ({ ...model, submitted: [...model.submitted, ...groupIDs] }));
216 beDeletedBatchIDs.push(...beDeletedAfterMergeIDs);
217 } else if (!signal.aborted) {
218 setModel((model) => ({ ...model, failedOnSubmit: [...model.failedOnSubmit, ...groupIDs] }));
221 if (!signal.aborted && !!beDeletedBatchIDs.length) {
222 await apiWithAbort(deleteContacts(beDeletedBatchIDs));
227 * Submit all merged contacts to the API
229 const submitContacts = async (
230 { contacts = [], labels }: { contacts: SimpleEncryptedContact[]; labels: number },
233 if (signal.aborted) {
236 // divide contacts and indexMap in batches
237 const contactBatches = chunk(contacts, ADD_CONTACTS_MAX_SIZE);
238 const apiCalls = contactBatches.length;
240 for (let i = 0; i < apiCalls; i++) {
241 // avoid overloading API in the case submitBatch is too fast
243 submitBatch({ contacts: contactBatches[i], labels }, { signal }),
244 // tripling the safe interval as there are reports of hitting jails on production (the proper solution would be a dynamic rate)
245 wait(3 * API_SAFE_INTERVAL),
251 * Delete contacts marked for deletion
253 const deleteMarkedForDeletion = async ({ signal }: Signal) => {
254 const beDeletedIDs = Object.keys(beDeletedModel);
255 if (!signal.aborted && !!beDeletedIDs.length) {
256 setModel((model) => ({ ...model, deleted: [...model.deleted, ...beDeletedIDs] }));
257 await apiWithAbort(deleteContacts(beDeletedIDs));
262 * All steps of the merge process
264 const mergeContacts = async ({ signal }: Signal) => {
265 const beSubmittedContacts = !alreadyMerged
266 ? await mergeAndEncrypt({ signal })
267 : await encryptAlreadyMerged({ signal });
268 const { withCategories, withoutCategories } = splitEncryptedContacts(beSubmittedContacts);
269 await submitContacts({ contacts: withCategories, labels: INCLUDE }, { signal });
270 await submitContacts({ contacts: withoutCategories, labels: IGNORE }, { signal });
271 await deleteMarkedForDeletion({ signal });
272 if (!signal.aborted) {
277 void withLoading(mergeContacts(abortController));
280 abortController.abort();
284 // Allocate 90% of the progress to merging and encrypting, 10% to sending to API
285 const combinedProgress = combineProgress([
288 successful: model.mergedAndEncrypted.length,
289 failed: model.failedOnMergeAndEncrypt.length,
290 total: totalBeMerged,
294 successful: model.submitted.length,
295 failed: model.failedOnSubmit.length,
296 total: totalBeMerged - model.failedOnMergeAndEncrypt.length,
299 const successDelete = model.deleted.length === totalBeDeleted;
300 const successMerge = model.failedOnMergeAndEncrypt.length + model.failedOnSubmit.length !== totalBeMerged;
302 const progressMessage = c('Progress bar description').t`Progress: ${combinedProgress}%`;
305 if (successDelete && !successMerge) {
306 endMessage = c('Progress bar description').ngettext(
307 msgid`${model.deleted.length} out of ${totalBeDeleted} contact successfully deleted.`,
308 `${model.deleted.length} out of ${totalBeDeleted} contacts successfully deleted.`,
311 } else if (successMerge) {
312 endMessage = c('Progress bar description').ngettext(
313 msgid`${model.submitted.length} out of ${totalBeMerged} contact successfully merged.`,
314 `${model.submitted.length} out of ${totalBeMerged} contacts successfully merged.`,
318 endMessage = c('Progress bar description').t`No contacts merged.`;
323 <ModalTwoHeader title={isDeleteOnly ? c('Title').t`Deleting contacts` : c('Title').t`Merging contacts`} />
325 <Alert className="mb-4">
328 .t`Merging contacts... This may take a few minutes. When the process is completed, you can close this modal.`
330 .t`Deleting contacts... This may take a few minutes. When the process is completed, you can close this modal.`}
333 id="progress-merge-contacts"
335 value={combinedProgress}
337 success={successMerge || successDelete}
338 display={loading ? progressMessage : endMessage}
339 data-testid="merge-model:progress-merge-contacts"
343 {!mergeFinished && <Button onClick={onClose}>{c('Action').t`Cancel`}</Button>}
346 loading={!mergeFinished}
348 data-testid="merge-model:close-button"
351 {c('Action').t`Close`}
358 export default ContactMergingContent;