Merge branch 'feat/rbf-wording' into 'main'
[ProtonMail-WebClient.git] / packages / drive-store / store / _uploads / UploadProvider / useUploadQueue.ts
blobf1a5f5668eb63ca153c4c57be9553142e95684b7
1 import { useCallback, useMemo, useState } from 'react';
3 import { c } from 'ttag';
5 import { DS_STORE } from '@proton/shared/lib/drive/constants';
6 import generateUID from '@proton/utils/generateUID';
8 import { TransferState } from '../../../components/TransferManager/transfer';
9 import {
10     isTransferConflict,
11     isTransferFinished,
12     isTransferInitializing,
13     isTransferPending,
14 } from '../../../utils/transfer';
15 import type { UploadFileItem, UploadFileList, UploadFolderItem } from '../interface';
16 import type {
17     FileUpload,
18     FileUploadReady,
19     FolderUpload,
20     FolderUploadReady,
21     UpdateCallback,
22     UpdateCallbackParams,
23     UpdateData,
24     UpdateFilter,
25     UpdateState,
26     UploadQueue,
27 } from './interface';
28 import { UploadConflictError, UploadUserError } from './interface';
30 type LogCallback = (id: string, message: string) => void;
32 export default function useUploadQueue(log: LogCallback) {
33     const [queue, setQueue] = useState<UploadQueue[]>([]);
35     const fileUploads = useMemo((): FileUpload[] => {
36         const f = ({ files, folders }: { files: FileUpload[]; folders: FolderUpload[] }): FileUpload[] => {
37             return [...files, ...folders.flatMap(f)];
38         };
39         return queue.flatMap(f);
40     }, [queue]);
42     const folderUploads = useMemo((): FolderUpload[] => {
43         const f = ({ folders }: { folders: FolderUpload[] }): FolderUpload[] => {
44             return [...folders, ...folders.flatMap(f)];
45         };
46         return queue.flatMap(f);
47     }, [queue]);
49     const allUploads = useMemo((): (FileUpload | FolderUpload)[] => {
50         return [...fileUploads, ...folderUploads];
51     }, [fileUploads, folderUploads]);
53     const hasUploads = useMemo((): boolean => {
54         return allUploads.length > 0;
55     }, [allUploads]);
57     const { nextFileUpload, nextFolderUpload } = useMemo(() => {
58         let nextFileUpload: FileUploadReady | undefined;
59         let nextFolderUpload: FolderUploadReady | undefined;
61         const conflictingUpload = allUploads.some(isTransferConflict);
62         if (conflictingUpload) {
63             return { nextFileUpload, nextFolderUpload };
64         }
66         nextFileUpload = fileUploads.find((file) => isTransferPending(file) && file.parentId) as FileUploadReady;
67         nextFolderUpload = folderUploads.find(
68             (folder) => isTransferPending(folder) && folder.parentId
69         ) as FolderUploadReady;
70         return { nextFileUpload, nextFolderUpload };
71     }, [allUploads, fileUploads, folderUploads]);
73     const add = useCallback(
74         async (
75             shareId: string,
76             parentId: string,
77             list: UploadFileList,
78             isForPhotos: boolean = false,
79             isSharedWithMe: boolean = false
80         ): Promise<void> => {
81             return new Promise((resolve, reject) => {
82                 setQueue((queue) => {
83                     const errors: Error[] = [];
84                     const conflictErrors: UploadConflictError[] = [];
86                     const queueItem = queue.find((item) => item.shareId === shareId && item.linkId === parentId) || {
87                         shareId,
88                         linkId: parentId,
89                         files: [],
90                         folders: [],
91                     };
92                     for (const item of list) {
93                         if ((item as UploadFileItem).file?.name === DS_STORE) {
94                             continue;
95                         }
96                         try {
97                             addItemToQueue(log, shareId, queueItem, item, isForPhotos, isSharedWithMe);
98                         } catch (err: any) {
99                             if ((err as Error).name === 'UploadConflictError') {
100                                 conflictErrors.push(err);
101                             } else {
102                                 errors.push(err);
103                             }
104                         }
105                     }
106                     const newQueue = [
107                         ...queue.filter((item) => item.shareId !== shareId || item.linkId !== parentId),
108                         queueItem,
109                     ];
111                     if (conflictErrors.length > 0) {
112                         errors.push(new UploadConflictError(conflictErrors[0].filename, conflictErrors.length - 1));
113                     }
114                     if (errors.length > 0) {
115                         reject(errors);
116                     } else {
117                         resolve();
118                     }
119                     return newQueue;
120                 });
121             });
122         },
123         []
124     );
126     const update = useCallback(
127         (
128             idOrFilter: UpdateFilter,
129             newStateOrCallback: UpdateState,
130             { mimeType, name, error, folderId, originalIsDraft, originalIsFolder }: UpdateData = {},
131             callback?: UpdateCallback
132         ) => {
133             const filter = convertFilterToFunction(idOrFilter);
134             const newStateCallback = convertNewStateToFunction(newStateOrCallback);
135             const updateFileOrFolder = <T extends FileUpload | FolderUpload>(item: T) => {
136                 callback?.(item);
137                 const newState = newStateCallback(item);
138                 // If pause is set twice, prefer resumeState set already before
139                 // to not be locked in paused state forever.
140                 item.resumeState = newState === TransferState.Paused ? item.resumeState || item.state : undefined;
141                 item.state = newState;
142                 if (mimeType) {
143                     item.meta.mimeType = mimeType;
144                 }
145                 if (name) {
146                     item.meta.filename = name;
147                 }
148                 if (originalIsDraft) {
149                     item.originalIsDraft = originalIsDraft;
150                 }
151                 if (originalIsFolder) {
152                     item.originalIsFolder = originalIsFolder;
153                 }
154                 item.error = error;
155                 if (!!error) {
156                     item.numberOfErrors++;
157                 }
158                 log(item.id, `Updated queue (state: ${newState}, error: ${error || ''})`);
159             };
160             const updateFile = (file: FileUpload): FileUpload => {
161                 if (filter(file)) {
162                     updateFileOrFolder(file);
163                 }
164                 return file;
165             };
166             const updateFolder = (folder: FolderUpload): FolderUpload => {
167                 if (filter(folder)) {
168                     // When parent folder is canceled, all childs would hang up
169                     // in initializing state - therefore we need to cancel
170                     // recursively all children.
171                     if (newStateCallback(folder) === TransferState.Canceled) {
172                         folder = recursiveCancel(log, folder);
173                     }
174                     updateFileOrFolder(folder);
175                     if (folderId) {
176                         folder.linkId = folderId;
177                         folder.files = folder.files.map((file) => {
178                             log(
179                                 file.id,
180                                 `Updated child (state: ${
181                                     file.state === TransferState.Initializing ? TransferState.Pending : file.state
182                                 })`
183                             );
184                             return {
185                                 ...file,
186                                 parentId: folderId,
187                                 state: file.state === TransferState.Initializing ? TransferState.Pending : file.state,
188                             };
189                         });
190                         folder.folders = folder.folders.map((folder) => {
191                             log(
192                                 folder.id,
193                                 `Updated child (state: ${
194                                     folder.state === TransferState.Initializing ? TransferState.Pending : folder.state
195                                 })`
196                             );
197                             return {
198                                 ...folder,
199                                 parentId: folderId,
200                                 state:
201                                     folder.state === TransferState.Initializing ? TransferState.Pending : folder.state,
202                             };
203                         });
204                     }
205                 }
206                 folder.files = folder.files.map(updateFile);
207                 folder.folders = folder.folders.map(updateFolder);
208                 // When any child is restarted after parent folder is canceled,
209                 // the child would hang up in initializing state - therefore we
210                 // need to restart also all canceled parents of that child.
211                 if (folder.state === TransferState.Canceled && hasInitializingUpload(folder)) {
212                     folder.state = folder.parentId ? TransferState.Pending : TransferState.Initializing;
213                 }
214                 return folder;
215             };
216             setQueue((queue) => [
217                 ...queue.map((item) => {
218                     item.files = item.files.map(updateFile);
219                     item.folders = item.folders.map(updateFolder);
220                     return item;
221                 }),
222             ]);
223         },
224         []
225     );
227     const updateState = useCallback(
228         (idOrFilter: UpdateFilter, newStateOrCallback: UpdateState) => {
229             update(idOrFilter, newStateOrCallback);
230         },
231         [update]
232     );
234     const updateWithData = useCallback(
235         (idOrFilter: UpdateFilter, newStateOrCallback: UpdateState, data: UpdateData = {}) => {
236             update(idOrFilter, newStateOrCallback, data);
237         },
238         [update]
239     );
241     const updateWithCallback = useCallback(
242         (idOrFilter: UpdateFilter, newStateOrCallback: UpdateState, callback: UpdateCallback) => {
243             update(idOrFilter, newStateOrCallback, {}, callback);
244         },
245         [update]
246     );
248     const remove = useCallback((idOrFilter: UpdateFilter, callback?: UpdateCallback) => {
249         const filter = convertFilterToFunction(idOrFilter);
250         const invertFilter: UpdateFilter = (item) => !filter(item);
252         setQueue((queue) => {
253             if (callback) {
254                 const recursiveCallback = (item: FolderUpload) => {
255                     callback(item);
256                     item.files.forEach((value) => callback(value));
257                     item.folders.forEach(recursiveCallback);
258                 };
259                 const doCallback = (item: UploadQueue | FolderUpload) => {
260                     item.files.filter(filter).forEach((value) => callback(value));
261                     item.folders.filter(filter).forEach(recursiveCallback);
262                     item.folders.forEach(doCallback);
263                 };
264                 queue.forEach(doCallback);
265             }
267             const doFilter = <T extends UploadQueue | FolderUpload>(item: T): T => {
268                 item.files = item.files.filter(invertFilter);
269                 item.folders = item.folders.filter(invertFilter).map(doFilter);
270                 return item;
271             };
272             return [...queue.map(doFilter)];
273         });
274     }, []);
276     const clear = useCallback(() => {
277         setQueue([]);
278     }, []);
280     return {
281         fileUploads,
282         folderUploads,
283         allUploads,
284         hasUploads,
285         nextFileUpload,
286         nextFolderUpload,
287         add,
288         updateState,
289         updateWithData,
290         updateWithCallback,
291         remove,
292         clear,
293     };
296 export function convertFilterToFunction(filterOrId: UpdateFilter) {
297     return typeof filterOrId === 'function' ? filterOrId : ({ id }: UpdateCallbackParams) => id === filterOrId;
300 function convertNewStateToFunction(newStateOrCallback: UpdateState) {
301     return typeof newStateOrCallback === 'function' ? newStateOrCallback : () => newStateOrCallback;
304 export function addItemToQueue(
305     log: LogCallback,
306     shareId: string,
307     newQueue: UploadQueue,
308     item: UploadFileItem | UploadFolderItem,
309     isForPhotos: boolean = false,
310     isSharedWithMe: boolean = false
311 ) {
312     const name = (item as UploadFileItem).file ? (item as UploadFileItem).file.name : (item as UploadFolderItem).folder;
313     if (!name) {
314         throw new UploadUserError(c('Notification').t`File or folder is missing a name`);
315     }
317     const part = findUploadQueueFolder(newQueue, item.path);
318     if (isNameAlreadyUploading(part, name)) {
319         throw new UploadConflictError(name);
320     }
322     const id = generateUID();
323     const state = part.linkId ? TransferState.Pending : TransferState.Initializing;
324     const generalAttributes = {
325         id,
326         shareId,
327         parentId: part.linkId,
328         state,
329         startDate: new Date(),
330         isForPhotos,
331         isSharedWithMe,
332     };
333     if ((item as UploadFileItem).file) {
334         const fileItem = item as UploadFileItem;
335         part.files.push({
336             ...generalAttributes,
337             file: fileItem.file,
338             meta: {
339                 filename: name,
340                 size: fileItem.file.size,
341                 mimeType: fileItem.file.type,
342             },
343             numberOfErrors: 0,
344         });
345         log(
346             id,
347             `Added file to the queue (state: ${state}, parent: ${part.id}, type: ${fileItem.file.type}, size: ${fileItem.file.size} bytes)`
348         );
349     } else {
350         const folderItem = item as UploadFolderItem;
351         part.folders.push({
352             ...generalAttributes,
353             name: folderItem.folder,
354             modificationTime: folderItem.modificationTime,
355             files: [],
356             folders: [],
357             meta: {
358                 filename: folderItem.folder,
359                 size: 0,
360                 mimeType: 'Folder',
361             },
362             numberOfErrors: 0,
363         });
364         log(id, `Added folder to the queue (state: ${state}, parent: ${part.id})`);
365     }
368 function findUploadQueueFolder(
369     part: UploadQueue | FolderUpload,
370     path: string[]
371 ): (UploadQueue & { id: string }) | FolderUpload {
372     if (path.length === 0) {
373         return {
374             id: 'root',
375             ...part,
376         };
377     }
379     const nextStep = path[0];
380     const sortedMatchingFolders = part.folders
381         // Find all folders with the same name. This can happen in situation
382         // when user uploads folder and after its done, user uploads it again.
383         .filter(({ name }) => name === nextStep)
384         // Sort it by date, the latest one is at the beginning of the array.
385         // We want to add new uploads to the latest folder, not the one which
386         // was already finished before.
387         .sort((a, b) => b.startDate.getTime() - a.startDate.getTime());
388     // Folders can have the same startDate (mostly in unit test, probably not
389     // in real world), but lets explicitely always prefer non finished folder
390     // to be super sure.
391     const folder = sortedMatchingFolders.find((folder) => !isTransferFinished(folder)) || sortedMatchingFolders[0];
392     if (folder) {
393         return findUploadQueueFolder(folder, path.slice(1));
394     }
396     throw new Error('Wrong file or folder structure');
399 function isNameAlreadyUploading(part: UploadQueue | FolderUpload, name: string): boolean {
400     const recursiveIsNotFinished = (upload: FolderUpload): boolean => {
401         return (
402             !isTransferFinished(upload) ||
403             upload.files.some((upload) => !isTransferFinished(upload)) ||
404             upload.folders.some(recursiveIsNotFinished)
405         );
406     };
407     return (
408         part.files.filter((upload) => !isTransferFinished(upload)).some(({ file }) => file.name === name) ||
409         part.folders.filter(recursiveIsNotFinished).some((folder) => folder.name === name)
410     );
413 function recursiveCancel(log: LogCallback, folder: FolderUpload): FolderUpload {
414     return {
415         ...folder,
416         files: folder.files.map((file) => {
417             log(file.id, 'Canceled by parent');
418             return {
419                 ...file,
420                 state: TransferState.Canceled,
421             };
422         }),
423         folders: folder.folders
424             .map((folder) => {
425                 log(folder.id, 'Canceled by parent');
426                 return {
427                     ...folder,
428                     state: TransferState.Canceled,
429                 };
430             })
431             .map((subfolder) => recursiveCancel(log, subfolder)),
432     };
435 function hasInitializingUpload(folder: FolderUpload): boolean {
436     return (
437         folder.files.some(isTransferInitializing) ||
438         folder.folders.some(isTransferInitializing) ||
439         folder.folders.some(hasInitializingUpload)
440     );