Remove payments API routing initialization
[ProtonMail-WebClient.git] / packages / components / containers / contacts / merge / ContactMergingContent.tsx
blob31b1fd5f2cba15be16fd585a1580d79cecb9abba
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 };
36 interface Props {
37     alreadyMerged?: VCardContact;
38     mergeFinished: boolean;
39     onFinish: () => void;
40     onMerged?: () => void;
41     onClose?: () => void;
42     beMergedModel: { [ID: string]: string[] };
43     beDeletedModel: { [ID: string]: string };
44     totalBeMerged: number;
45     totalBeDeleted: number;
48 const ContactMergingContent = ({
49     alreadyMerged,
50     mergeFinished,
51     onFinish,
52     onMerged,
53     onClose,
54     beMergedModel = {},
55     beDeletedModel = {},
56     totalBeMerged = 0,
57     totalBeDeleted = 0,
58 }: Props) => {
59     const api = useApi();
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[];
67         submitted: string[];
68         failedOnSubmit: string[];
69         deleted: string[];
70     }>({
71         mergedAndEncrypted: [],
72         failedOnMergeAndEncrypt: [],
73         submitted: [],
74         failedOnSubmit: [],
75         deleted: [],
76     });
78     const isDeleteOnly = totalBeMerged <= 0 && totalBeDeleted > 0;
80     useEffect(() => {
81         // Prepare api for allowing cancellation in the middle of the merge
82         const abortController = new AbortController();
83         const apiWithAbort = getApiWithAbort(api, abortController.signal);
85         /**
86          * Get a contact from its ID and decrypt it. Return contact as a list of properties
87          */
88         const getDecryptedContact = async (ID: string, { signal }: Signal): Promise<VCardContact> => {
89             if (signal.aborted) {
90                 return { fn: [] };
91             }
92             const { Contact } = await apiWithAbort<{ Contact: ContactType }>(getContact(ID));
93             const { vCardContact, errors: contactErrors } = await decrypt(Contact, {
94                 privateKeys,
95                 publicKeys,
96             });
97             if (contactErrors.length) {
98                 throw new Error(`Error decrypting contact ${ID}`);
99             }
100             return vCardContact;
101         };
103         /**
104          * Get and decrypt a group of contacts to be merged. Return array of decrypted contacts
105          */
106         const getDecryptedGroup = (groupIDs: string[] = [], { signal }: Signal) => {
107             return processApiRequestsSafe(
108                 groupIDs.map((ID) => () => getDecryptedContact(ID, { signal })),
109                 3,
110                 1000
111             );
112         };
114         /**
115          * Encrypt a contact already merged. Useful for the case of `preview merge`
116          */
117         const encryptAlreadyMerged = async ({ signal }: Signal) => {
118             if (signal.aborted) {
119                 return [];
120             }
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');
126             }
127             try {
128                 const encryptedMergedContact = await encrypt(alreadyMerged, {
129                     privateKey: privateKeys[0],
130                     publicKey: publicKeys[0],
131                 });
132                 beSubmittedContacts.push({ contact: encryptedMergedContact, contactId: beMergedID });
134                 if (!signal.aborted) {
135                     setModel((model) => ({ ...model, mergedAndEncrypted: [...model.mergedAndEncrypted, ...groupIDs] }));
136                 }
137             } catch {
138                 if (!signal.aborted) {
139                     setModel((model) => ({
140                         ...model,
141                         failedOnMergeAndEncrypt: [...model.failedOnMergeAndEncrypt, ...groupIDs],
142                     }));
143                 }
144             }
145             return beSubmittedContacts;
146         };
148         /**
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
151          */
152         const mergeAndEncrypt = async ({ signal }: Signal) => {
153             const beSubmittedContacts: SimpleEncryptedContact[] = [];
154             for (const [beMergedID, groupIDs] of Object.entries(beMergedModel)) {
155                 if (signal.aborted) {
156                     return [];
157                 }
158                 try {
159                     const decryptedGroup = await getDecryptedGroup(groupIDs, { signal });
160                     const encryptedMergedContact = await encrypt(merge(decryptedGroup), {
161                         privateKey: privateKeys[0],
162                         publicKey: publicKeys[0],
163                     });
164                     beSubmittedContacts.push({ contact: encryptedMergedContact, contactId: beMergedID });
165                     if (!signal.aborted) {
166                         setModel((model) => ({
167                             ...model,
168                             mergedAndEncrypted: [...model.mergedAndEncrypted, ...groupIDs],
169                         }));
170                     }
171                 } catch (error) {
172                     if (!signal.aborted) {
173                         setModel((model) => ({
174                             ...model,
175                             failedOnMergeAndEncrypt: [...model.failedOnMergeAndEncrypt, ...groupIDs],
176                         }));
177                     }
178                 }
179             }
180             return beSubmittedContacts;
181         };
183         /**
184          * Submit a batch of merged contacts to the API
185          */
186         const submitBatch = async (
187             { contacts = [], labels }: { contacts: SimpleEncryptedContact[]; labels: number },
188             { signal }: Signal
189         ) => {
190             if (signal.aborted || !contacts.length) {
191                 return;
192             }
193             const beDeletedBatchIDs = [];
194             const responses = (
195                 await apiWithAbort<{ Responses: { Response: { Code: number; Contact?: ContactType } }[] }>(
196                     addContacts({
197                         Contacts: contacts.map(({ contact }) => contact),
198                         Overwrite: OVERWRITE_CONTACT,
199                         Labels: labels,
200                     })
201                 )
202             ).Responses.map(({ Response }: any) => Response);
204             if (signal.aborted) {
205                 return;
206             }
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] }));
215                     }
216                     beDeletedBatchIDs.push(...beDeletedAfterMergeIDs);
217                 } else if (!signal.aborted) {
218                     setModel((model) => ({ ...model, failedOnSubmit: [...model.failedOnSubmit, ...groupIDs] }));
219                 }
220             }
221             if (!signal.aborted && !!beDeletedBatchIDs.length) {
222                 await apiWithAbort(deleteContacts(beDeletedBatchIDs));
223             }
224         };
226         /**
227          * Submit all merged contacts to the API
228          */
229         const submitContacts = async (
230             { contacts = [], labels }: { contacts: SimpleEncryptedContact[]; labels: number },
231             { signal }: Signal
232         ) => {
233             if (signal.aborted) {
234                 return;
235             }
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
242                 await Promise.all([
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),
246                 ]);
247             }
248         };
250         /**
251          * Delete contacts marked for deletion
252          */
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));
258             }
259         };
261         /**
262          * All steps of the merge process
263          */
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) {
273                 onFinish();
274             }
275         };
277         void withLoading(mergeContacts(abortController));
279         return () => {
280             abortController.abort();
281         };
282     }, []);
284     // Allocate 90% of the progress to merging and encrypting, 10% to sending to API
285     const combinedProgress = combineProgress([
286         {
287             allocated: 0.9,
288             successful: model.mergedAndEncrypted.length,
289             failed: model.failedOnMergeAndEncrypt.length,
290             total: totalBeMerged,
291         },
292         {
293             allocated: 0.1,
294             successful: model.submitted.length,
295             failed: model.failedOnSubmit.length,
296             total: totalBeMerged - model.failedOnMergeAndEncrypt.length,
297         },
298     ]);
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}%`;
304     let endMessage;
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.`,
309             totalBeDeleted
310         );
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.`,
315             totalBeMerged
316         );
317     } else {
318         endMessage = c('Progress bar description').t`No contacts merged.`;
319     }
321     return (
322         <>
323             <ModalTwoHeader title={isDeleteOnly ? c('Title').t`Deleting contacts` : c('Title').t`Merging contacts`} />
324             <ModalTwoContent>
325                 <Alert className="mb-4">
326                     {totalBeMerged > 0
327                         ? c('Description')
328                               .t`Merging contacts... This may take a few minutes. When the process is completed, you can close this modal.`
329                         : c('Description')
330                               .t`Deleting contacts... This may take a few minutes. When the process is completed, you can close this modal.`}
331                 </Alert>
332                 <DynamicProgress
333                     id="progress-merge-contacts"
334                     loading={loading}
335                     value={combinedProgress}
336                     max={100}
337                     success={successMerge || successDelete}
338                     display={loading ? progressMessage : endMessage}
339                     data-testid="merge-model:progress-merge-contacts"
340                 />
341             </ModalTwoContent>
342             <ModalTwoFooter>
343                 {!mergeFinished && <Button onClick={onClose}>{c('Action').t`Cancel`}</Button>}
344                 <Button
345                     color="norm"
346                     loading={!mergeFinished}
347                     onClick={onMerged}
348                     data-testid="merge-model:close-button"
349                     className="ml-auto"
350                 >
351                     {c('Action').t`Close`}
352                 </Button>
353             </ModalTwoFooter>
354         </>
355     );
358 export default ContactMergingContent;