Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / drive / src / app / store / _links / useLinksState.tsx
blobe82ab1c1c09713105624131716b17bcddcccfe43
1 import { createContext, useCallback, useContext, useEffect, useState } from 'react';
3 import { EVENT_TYPES } from '@proton/shared/lib/drive/constants';
4 import isTruthy from '@proton/utils/isTruthy';
6 import type { DriveEvent, DriveEvents } from '../_events';
7 import { useDriveEventManager } from '../_events';
8 import type { DecryptedLink, EncryptedLink, LinkShareUrl, SignatureIssues } from './interface';
9 import { isDecryptedLinkSame, isEncryptedLinkSame } from './link';
11 export type LinksState = {
12     [shareId: string]: {
13         links: Links;
14         tree: Tree;
15         // Timestamp of the last "Empty Trash" action to properly compute
16         // isLocked flag for newly added links. Links added later needs
17         // to have isLocked based on the information if API will delete it
18         // or not.
19         latestTrashEmptiedAt?: number;
20     };
23 type Links = {
24     [linkId: string]: Link;
27 export type Link = {
28     encrypted: EncryptedLink;
29     decrypted?: DecryptedLink;
32 type Tree = {
33     [parentLinkId: string]: string[];
36 /**
37  * Returns whether or not a `Link` is decrypted.
38  */
39 export function isLinkDecrypted(link: Link | undefined): link is Required<Link> {
40     return !!link && !!link.decrypted && !link.decrypted.isStale;
43 /**
44  * useLinksStateProvider provides a storage to cache links.
45  */
46 export function useLinksStateProvider() {
47     const eventsManager = useDriveEventManager();
49     const [state, setState] = useState<LinksState>({});
51     useEffect(() => {
52         const callbackId = eventsManager.eventHandlers.register((_volumeId, events, processedEventCounter) =>
53             setState((state) => updateByEvents(state, events, processedEventCounter))
54         );
55         return () => {
56             eventsManager.eventHandlers.unregister(callbackId);
57         };
58     }, []);
60     const setLinks = useCallback((shareId: string, links: Link[]) => {
61         setState((state) => addOrUpdate(state, shareId, links));
62     }, []);
64     const lockLinks = useCallback((shareId: string, linkIds: string[]) => {
65         setState((state) => setLock(state, shareId, linkIds, true));
66     }, []);
68     const unlockLinks = useCallback((shareId: string, linkIds: string[]) => {
69         setState((state) => setLock(state, shareId, linkIds, false));
70     }, []);
72     const lockTrash = useCallback(() => {
73         setState((state) =>
74             Object.keys(state).reduce((acc, shareId) => {
75                 return setLock(acc, shareId, 'trash', true);
76             }, state)
77         );
78     }, []);
80     const setCachedThumbnail = useCallback((shareId: string, linkId: string, url: string) => {
81         setState((state) => setCachedThumbnailUrl(state, shareId, linkId, url));
82     }, []);
84     const getLink = useCallback(
85         (shareId: string, linkId: string): Link | undefined => {
86             return state[shareId]?.links[linkId];
87         },
88         [state]
89     );
91     const getChildren = useCallback(
92         (shareId: string, parentLinkId: string, foldersOnly: boolean = false): Link[] => {
93             const childrenLinkIds = state[shareId]?.tree[parentLinkId] || [];
94             return childrenLinkIds
95                 .map((linkId) => state[shareId].links[linkId])
96                 .filter(isTruthy)
97                 .filter((link) => !foldersOnly || !link.encrypted.isFile);
98         },
99         [state]
100     );
102     const getAllShareLinks = (shareId: string): Link[] => {
103         return Object.values(state[shareId]?.links || []);
104     };
106     const getTrashed = useCallback(
107         (shareId: string): Link[] => {
108             return getAllShareLinks(shareId).filter(
109                 (link) => !!link.encrypted.trashed && !link.encrypted.trashedByParent
110             );
111         },
112         [state]
113     );
115     const getSharedByLink = useCallback(
116         (shareId: string): Link[] => {
117             return getAllShareLinks(shareId).filter(
118                 ({ encrypted }) =>
119                     !encrypted.trashed &&
120                     !!encrypted.sharingDetails?.shareId &&
121                     encrypted.sharingDetails.shareId !== encrypted.rootShareId
122             );
123         },
124         [state]
125     );
127     const getSharedWithMeByLink = useCallback(
128         (shareId: string): Link[] => {
129             return getAllShareLinks(shareId).filter(
130                 ({ encrypted }) =>
131                     !encrypted.trashed &&
132                     !!encrypted.sharingDetails?.shareId &&
133                     encrypted.sharingDetails.shareId === encrypted.rootShareId
134             );
135         },
136         [state]
137     );
139     const removeLinkForMigration = useCallback(
140         (shareId: string, linkId: string) => {
141             setState((state) => deleteLinks(state, shareId, [linkId]));
142         },
143         [state]
144     );
146     // TODO: Remove this when events or refactor will be in place
147     const removeLinkForSharedWithMe = useCallback(
148         (shareId: string, linkId: string) => {
149             setState((state) => deleteLinks(state, shareId, [linkId]));
150         },
151         [state]
152     );
154     return {
155         setLinks,
156         lockLinks,
157         unlockLinks,
158         lockTrash,
159         setCachedThumbnail,
160         getLink,
161         getChildren,
162         getTrashed,
163         getSharedByLink,
164         getSharedWithMeByLink,
165         getAllShareLinks, // This should be use only in specific case when you know the links you need (Ex: Bookmarks)
166         removeLinkForMigration,
167         removeLinkForSharedWithMe,
168     };
171 export function updateByEvents(
172     state: LinksState,
173     { events, eventId }: DriveEvents,
174     processedEventcounter: (eventId: string, event: DriveEvent) => void
175 ): LinksState {
176     events.forEach((event) => {
177         if (event.eventType === EVENT_TYPES.DELETE) {
178             // Delete event does not contain context share ID because
179             // the link is already deleted and backend might not know
180             // it anymore. There might be two links in mulitple shares
181             // with the same link IDs, but it is very rare, and it can
182             // happen in user cache only with direct sharing. Because
183             // the risk is almost zero, it is simply deleting all the
184             // links with the given ID. In future when we have full sync
185             // we will have storage mapped with volumes instead removing
186             // the problem altogether.
187             Object.keys(state).forEach((shareId) => {
188                 state = deleteLinks(state, shareId, [event.encryptedLink.linkId], () => {
189                     processedEventcounter(eventId, event);
190                 });
191             });
192             return;
193         }
195         // If link is moved from one share to the another one, we need
196         // to delete it from the original one too. It is not very efficient
197         // as it will delete the decrypted content. But this is rare and
198         // it will be solved in the future with volume-centric approach
199         // as described above.
200         if (
201             event.originShareId &&
202             event.encryptedLink.rootShareId !== event.originShareId &&
203             state[event.originShareId]
204         ) {
205             state = deleteLinks(state, event.originShareId, [event.encryptedLink.linkId], () => {
206                 processedEventcounter(eventId, event);
207             });
208         }
210         if (!state[event.encryptedLink.rootShareId]) {
211             return state;
212         }
213         state = addOrUpdate(state, event.encryptedLink.rootShareId, [{ encrypted: event.encryptedLink }], () => {
214             processedEventcounter(eventId, event);
215         });
216     });
218     return state;
221 export function deleteLinks(state: LinksState, shareId: string, linkIds: string[], onDelete?: () => void): LinksState {
222     if (!state[shareId]) {
223         return state;
224     }
226     let updated = false;
227     linkIds.forEach((linkId) => {
228         const original = state[shareId].links[linkId];
229         if (!original) {
230             return;
231         }
233         updated = true;
235         // Delete the link itself from links and tree.
236         delete state[shareId].links[linkId];
238         onDelete?.();
240         const originalParentChildren = state[shareId].tree[original.encrypted.parentLinkId];
241         if (originalParentChildren) {
242             state[shareId].tree[original.encrypted.parentLinkId] = originalParentChildren.filter(
243                 (childLinkId) => childLinkId !== linkId
244             );
245         }
247         // Delete the root and children of the deleting link.
248         state[shareId].tree[linkId]?.forEach((childLinkId) => {
249             delete state[shareId].links[childLinkId];
250         });
251         delete state[shareId].tree[linkId];
252     });
254     return updated ? { ...state } : state;
257 export function addOrUpdate(state: LinksState, shareId: string, links: Link[], onAddOrUpdate?: () => void): LinksState {
258     if (!links.length) {
259         return state;
260     }
262     let stateUpdated = false;
264     if (!state[shareId]) {
265         state[shareId] = {
266             links: {},
267             tree: {},
268         };
269     }
271     links.forEach((link) => {
272         const { linkId, parentLinkId } = link.encrypted;
274         const original = state[shareId].links[linkId];
275         const originalTrashed = original?.encrypted.trashed;
277         // Backend does not return trashed property set for children of trashed
278         // parent. For example, file can have trashed equal to null even if its
279         // in the folder which is trashed. Its heavy operation on backend and
280         // because client needs to load all the parents to get keys anyway, we
281         // can calculate it here.
282         // Note this can be problematic in the future once we dont keep the full
283         // cache from memory consuption reasons. That will need more thoughts
284         // how to tackle this problem to keep the trashed property just fine.
285         if (!link.encrypted.trashed) {
286             const parentLinkTrashed = getParentTrashed(state, shareId, parentLinkId);
287             let trashedProps;
288             if (parentLinkTrashed) {
289                 trashedProps = {
290                     trashed: parentLinkTrashed,
291                     trashedByParent: true,
292                 };
293             } else if (original?.encrypted.trashedByParent) {
294                 // If the link do not belong under trashed tree anymore, and
295                 // the link is trashed by parent, we can reset it back.
296                 trashedProps = {
297                     trashed: null,
298                     trashedByParent: false,
299                 };
300             }
301             if (trashedProps) {
302                 link = {
303                     encrypted: { ...link.encrypted, ...trashedProps },
304                     decrypted: link.decrypted ? { ...link.decrypted, ...trashedProps } : undefined,
305                 };
306             }
307         }
309         if (original) {
310             stateUpdated = true;
311             const originalParentId = original.encrypted.parentLinkId;
312             if (originalParentId !== parentLinkId) {
313                 const originalParentChildren = state[shareId].tree[originalParentId];
314                 if (originalParentChildren) {
315                     state[shareId].tree[originalParentId] = originalParentChildren.filter(
316                         (childLinkId) => childLinkId !== linkId
317                     );
318                 }
319             }
321             const newSignatureIssues = getNewSignatureIssues(original.encrypted, link);
323             original.decrypted = getNewDecryptedLink(original, link);
324             original.encrypted = link.encrypted;
326             original.encrypted.signatureIssues = newSignatureIssues;
327             if (original.decrypted) {
328                 original.decrypted.signatureIssues = newSignatureIssues;
329             }
330         } else {
331             state[shareId].links[linkId] = link;
332         }
334         // Lock newly loaded trashed link if the whole trash is locked.
335         // For example, when trash is being emptied but at the same time
336         // the next page is loaded.
337         const lastTrashed = state[shareId].latestTrashEmptiedAt;
338         const cachedLink = state[shareId].links[linkId].decrypted;
339         if (cachedLink?.trashed && lastTrashed && cachedLink.trashed < lastTrashed) {
340             cachedLink.isLocked = true;
341         }
343         // Only root link has no parent ID.
344         if (parentLinkId) {
345             const parentChildIds = state[shareId].tree[parentLinkId];
346             if (parentChildIds) {
347                 // If the parent is trashed, we keep the tree structure, so we
348                 // can update properly trashed flag for all children after
349                 // parent is restored.
350                 if (link.encrypted.trashedByParent) {
351                     if (!parentChildIds.includes(linkId)) {
352                         stateUpdated = true;
353                         state[shareId].tree[parentLinkId] = [...parentChildIds, linkId];
354                     }
355                 } else if (link.encrypted.trashed) {
356                     stateUpdated = true;
357                     state[shareId].tree[parentLinkId] = parentChildIds.filter((childId) => childId !== linkId);
358                     recursivelyTrashChildren(state, shareId, linkId, link.encrypted.trashed);
359                 } else {
360                     if (!parentChildIds.includes(linkId)) {
361                         stateUpdated = true;
362                         state[shareId].tree[parentLinkId] = [...parentChildIds, linkId];
363                     }
364                     if (originalTrashed) {
365                         stateUpdated = true;
366                         recursivelyRestoreChildren(state, shareId, linkId, originalTrashed);
367                     }
368                 }
369             } else {
370                 stateUpdated = true;
371                 state[shareId].tree[parentLinkId] = [linkId];
372             }
373         }
374     });
376     if (stateUpdated && onAddOrUpdate) {
377         onAddOrUpdate();
378     }
380     return { ...state };
384  * getParentTrashed finds closest parent which is trashed and returns its
385  * trashed property, or returns null if link is not belonging under trashed
386  * folder.
387  */
388 function getParentTrashed(state: LinksState, shareId: string, linkId: string): number | null {
389     while (linkId) {
390         const link = state[shareId].links[linkId];
391         if (!link) {
392             return null;
393         }
394         if (link.encrypted.trashed) {
395             return link.encrypted.trashed;
396         }
397         linkId = link.encrypted.parentLinkId;
398     }
399     return null;
403  * recursivelyTrashChildren sets trashed flag to all children of the parent.
404  * When parent is trashed, API do not create event for every child, therefore
405  * we need to update trashed flag the same way for all of them in our cache.
406  */
407 function recursivelyTrashChildren(state: LinksState, shareId: string, linkId: string, trashed: number) {
408     recursivelyUpdateLinks(state, shareId, linkId, (link) => {
409         return {
410             encrypted: {
411                 ...link.encrypted,
412                 trashed: link.encrypted.trashed || trashed,
413                 trashedByParent: true,
414             },
415             decrypted: link.decrypted
416                 ? {
417                       ...link.decrypted,
418                       trashed: link.decrypted.trashed || trashed,
419                       trashedByParent: true,
420                   }
421                 : undefined,
422         };
423     });
427  * recursivelyRestoreChildren unsets trashed flag to children of the parent.
428  * It's similar to trashing: API do not create event for the childs, therefore
429  * we need to remove trashed flag from children but only the ones which have
430  * the same value because if the child was trashed first and then parent, user
431  * will restore only parent and the previosly trashed child still needs to stay
432  * in trash.
433  */
434 function recursivelyRestoreChildren(state: LinksState, shareId: string, linkId: string, originalTrashed: number) {
435     recursivelyUpdateLinks(state, shareId, linkId, (link) => {
436         if (link.encrypted.trashed !== originalTrashed) {
437             return link;
438         }
439         return {
440             encrypted: {
441                 ...link.encrypted,
442                 trashed: null,
443                 trashedByParent: false,
444             },
445             decrypted: link.decrypted
446                 ? {
447                       ...link.decrypted,
448                       trashed: null,
449                       trashedByParent: false,
450                   }
451                 : undefined,
452         };
453     });
457  * recursivelyUpdateLinks recursively calls updateCallback for every cached
458  * child of the provided linkId in scope of shareId.
459  */
460 function recursivelyUpdateLinks(
461     state: LinksState,
462     shareId: string,
463     linkId: string,
464     updateCallback: (link: Link) => Link
465 ) {
466     state[shareId].tree[linkId]?.forEach((linkId) => {
467         const child = state[shareId].links[linkId];
468         if (!child) {
469             return;
470         }
471         state[shareId].links[linkId] = updateCallback(child);
472         recursivelyUpdateLinks(state, shareId, child.encrypted.linkId, updateCallback);
473     });
476 function getNewSignatureIssues(original: EncryptedLink, newLink: Link): SignatureIssues | undefined {
477     const newSignatureIssues = newLink.decrypted?.signatureIssues || newLink.encrypted.signatureIssues;
478     const isSame = isEncryptedLinkSame(original, newLink.encrypted);
479     // If the link is different (different keys or new version of encrypted
480     // values), we need to forget all previous signature issues and try decrypt
481     // them again, or accept new issues if it was already tried.
482     if (!isSame) {
483         return newSignatureIssues;
484     }
485     if (original.signatureIssues || newSignatureIssues) {
486         return { ...original.signatureIssues, ...newSignatureIssues };
487     }
488     return undefined;
492  * getNewDecryptedLink returns new version of decrypted link. It tries to
493  * preserve the locally cached data, such as thumbnail or isLocked flag.
494  * If the `newLink` has decrypted version, it is used directly and enhanced
495  * with `getDecryptedLinkComputedData`.
496  * If the `original` link has decrypted version, the new decrypted link
497  * is combination of `newLink` encrypted version, `original` decrypted
498  * values (such as name or fileModifyTime), and locally computed data.
499  * If the case the new decrypted link doesn't match with previous encrypted
500  * data and needs re-decryption, `isStale` is set for later decryption.
501  * Decryption is not done right away, because the link might not be needed;
502  * any view which needs data needs to make sure to run code to re-decrypt
503  * stale links. The link is not cleared to not cause blinks in the UI.
504  */
505 function getNewDecryptedLink(original: Link, newLink: Link): DecryptedLink | undefined {
506     if (newLink.decrypted) {
507         return {
508             ...newLink.decrypted,
509             ...getDecryptedLinkComputedData(
510                 original.decrypted,
511                 newLink.decrypted.activeRevision?.id,
512                 newLink.decrypted.shareUrl
513             ),
514         };
515     }
516     if (original.decrypted) {
517         return {
518             ...newLink.encrypted,
519             encryptedName: original.decrypted.encryptedName,
520             name: original.decrypted.name,
521             fileModifyTime: original.decrypted.fileModifyTime,
522             duration: original.decrypted.duration,
523             corruptedLink: original.decrypted.corruptedLink,
524             ...getDecryptedLinkComputedData(
525                 original.decrypted,
526                 newLink.encrypted.activeRevision?.id,
527                 newLink.encrypted.shareUrl
528             ),
529             isStale: !isDecryptedLinkSame(original.encrypted, newLink.encrypted),
530         };
531     }
532     return undefined;
536  * getDecryptedLinkComputedData returns locally computed data.
537  * The list includes:
538  *  - numAccesses from shareUrl,
539  *  - isLocked,
540  *  - and cachedThumbnailUrl.
541  */
542 function getDecryptedLinkComputedData(link?: DecryptedLink, newRevisionId?: string, newShareUrl?: LinkShareUrl) {
543     return !link
544         ? {}
545         : {
546               shareUrl: newShareUrl
547                   ? {
548                         ...newShareUrl,
549                         numAccesses: getNewNumAccesses(newShareUrl, link),
550                     }
551                   : undefined,
552               isLocked: link.isLocked,
553               cachedThumbnailUrl: link.activeRevision?.id === newRevisionId ? link.cachedThumbnailUrl : undefined,
554           };
557 function getNewNumAccesses(newShareUrl: LinkShareUrl, oldLink?: DecryptedLink): number | undefined {
558     // Prefer the one coming from the new share URL info if set.
559     if (newShareUrl.numAccesses !== undefined) {
560         return newShareUrl.numAccesses;
561     }
562     // If not set, but we have it cached from before, use that.
563     // This information is not part of every response.
564     if (oldLink?.shareUrl?.numAccesses !== undefined) {
565         return oldLink.shareUrl.numAccesses;
566     }
567     // If there is no old share URL, but there is incoming one, that
568     // means it is freshly created share URL. In other words, it was
569     // not shared yet. We can safely set zero in such case so we don't
570     // have to do extra request to get zero.
571     if (oldLink && !oldLink.shareUrl) {
572         return 0;
573     }
574     // In all other cases keep undefined. We just don't know.
575     return undefined;
578 export function setLock(
579     state: LinksState,
580     shareId: string,
581     linkIdsOrTrash: string[] | 'trash',
582     isLocked: boolean
583 ): LinksState {
584     if (!state[shareId]) {
585         return state;
586     }
588     if (Array.isArray(linkIdsOrTrash)) {
589         linkIdsOrTrash.forEach((linkId) => {
590             if (!state[shareId].links[linkId]?.decrypted) {
591                 return;
592             }
594             state[shareId].links[linkId].decrypted = {
595                 ...(state[shareId].links[linkId].decrypted as DecryptedLink),
596                 isLocked,
597             };
598         });
599     } else {
600         state[shareId].latestTrashEmptiedAt = Date.now() / 1000; // From ms to sec.
601         Object.entries(state[shareId].links)
602             .filter(([, link]) => link.decrypted && link.decrypted.trashed)
603             .forEach(([linkId, link]) => {
604                 state[shareId].links[linkId].decrypted = {
605                     ...(link.decrypted as DecryptedLink),
606                     isLocked,
607                 };
608             });
609     }
610     return { ...state };
613 export function setCachedThumbnailUrl(
614     state: LinksState,
615     shareId: string,
616     linkId: string,
617     cachedThumbnailUrl: string
618 ): LinksState {
619     if (!state[shareId]) {
620         return state;
621     }
623     const link = state[shareId].links[linkId];
624     if (!link?.decrypted) {
625         return state;
626     }
628     link.decrypted = {
629         ...link.decrypted,
630         cachedThumbnailUrl,
631     };
632     return { ...state };
635 const LinksStateContext = createContext<ReturnType<typeof useLinksStateProvider> | null>(null);
637 export function LinksStateProvider({ children }: { children: React.ReactNode }) {
638     const value = useLinksStateProvider();
639     return <LinksStateContext.Provider value={value}>{children}</LinksStateContext.Provider>;
642 export default function useLinksState() {
643     const state = useContext(LinksStateContext);
644     if (!state) {
645         throw new Error('Trying to use uninitialized LinksStateProvider');
646     }
647     return state;