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 = {
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
19 latestTrashEmptiedAt?: number;
24 [linkId: string]: Link;
28 encrypted: EncryptedLink;
29 decrypted?: DecryptedLink;
33 [parentLinkId: string]: string[];
37 * Returns whether or not a `Link` is decrypted.
39 export function isLinkDecrypted(link: Link | undefined): link is Required<Link> {
40 return !!link && !!link.decrypted && !link.decrypted.isStale;
44 * useLinksStateProvider provides a storage to cache links.
46 export function useLinksStateProvider() {
47 const eventsManager = useDriveEventManager();
49 const [state, setState] = useState<LinksState>({});
52 const callbackId = eventsManager.eventHandlers.register((_volumeId, events, processedEventCounter) =>
53 setState((state) => updateByEvents(state, events, processedEventCounter))
56 eventsManager.eventHandlers.unregister(callbackId);
60 const setLinks = useCallback((shareId: string, links: Link[]) => {
61 setState((state) => addOrUpdate(state, shareId, links));
64 const lockLinks = useCallback((shareId: string, linkIds: string[]) => {
65 setState((state) => setLock(state, shareId, linkIds, true));
68 const unlockLinks = useCallback((shareId: string, linkIds: string[]) => {
69 setState((state) => setLock(state, shareId, linkIds, false));
72 const lockTrash = useCallback(() => {
74 Object.keys(state).reduce((acc, shareId) => {
75 return setLock(acc, shareId, 'trash', true);
80 const setCachedThumbnail = useCallback((shareId: string, linkId: string, url: string) => {
81 setState((state) => setCachedThumbnailUrl(state, shareId, linkId, url));
84 const getLink = useCallback(
85 (shareId: string, linkId: string): Link | undefined => {
86 return state[shareId]?.links[linkId];
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])
97 .filter((link) => !foldersOnly || !link.encrypted.isFile);
102 const getAllShareLinks = (shareId: string): Link[] => {
103 return Object.values(state[shareId]?.links || []);
106 const getTrashed = useCallback(
107 (shareId: string): Link[] => {
108 return getAllShareLinks(shareId).filter(
109 (link) => !!link.encrypted.trashed && !link.encrypted.trashedByParent
115 const getSharedByLink = useCallback(
116 (shareId: string): Link[] => {
117 return getAllShareLinks(shareId).filter(
119 !encrypted.trashed &&
120 !!encrypted.sharingDetails?.shareId &&
121 encrypted.sharingDetails.shareId !== encrypted.rootShareId
127 const getSharedWithMeByLink = useCallback(
128 (shareId: string): Link[] => {
129 return getAllShareLinks(shareId).filter(
131 !encrypted.trashed &&
132 !!encrypted.sharingDetails?.shareId &&
133 encrypted.sharingDetails.shareId === encrypted.rootShareId
139 const removeLinkForMigration = useCallback(
140 (shareId: string, linkId: string) => {
141 setState((state) => deleteLinks(state, shareId, [linkId]));
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]));
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,
171 export function updateByEvents(
173 { events, eventId }: DriveEvents,
174 processedEventcounter: (eventId: string, event: DriveEvent) => void
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);
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.
201 event.originShareId &&
202 event.encryptedLink.rootShareId !== event.originShareId &&
203 state[event.originShareId]
205 state = deleteLinks(state, event.originShareId, [event.encryptedLink.linkId], () => {
206 processedEventcounter(eventId, event);
210 if (!state[event.encryptedLink.rootShareId]) {
213 state = addOrUpdate(state, event.encryptedLink.rootShareId, [{ encrypted: event.encryptedLink }], () => {
214 processedEventcounter(eventId, event);
221 export function deleteLinks(state: LinksState, shareId: string, linkIds: string[], onDelete?: () => void): LinksState {
222 if (!state[shareId]) {
227 linkIds.forEach((linkId) => {
228 const original = state[shareId].links[linkId];
235 // Delete the link itself from links and tree.
236 delete state[shareId].links[linkId];
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
247 // Delete the root and children of the deleting link.
248 state[shareId].tree[linkId]?.forEach((childLinkId) => {
249 delete state[shareId].links[childLinkId];
251 delete state[shareId].tree[linkId];
254 return updated ? { ...state } : state;
257 export function addOrUpdate(state: LinksState, shareId: string, links: Link[], onAddOrUpdate?: () => void): LinksState {
262 let stateUpdated = false;
264 if (!state[shareId]) {
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);
288 if (parentLinkTrashed) {
290 trashed: parentLinkTrashed,
291 trashedByParent: true,
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.
298 trashedByParent: false,
303 encrypted: { ...link.encrypted, ...trashedProps },
304 decrypted: link.decrypted ? { ...link.decrypted, ...trashedProps } : undefined,
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
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;
331 state[shareId].links[linkId] = link;
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;
343 // Only root link has no parent ID.
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)) {
353 state[shareId].tree[parentLinkId] = [...parentChildIds, linkId];
355 } else if (link.encrypted.trashed) {
357 state[shareId].tree[parentLinkId] = parentChildIds.filter((childId) => childId !== linkId);
358 recursivelyTrashChildren(state, shareId, linkId, link.encrypted.trashed);
360 if (!parentChildIds.includes(linkId)) {
362 state[shareId].tree[parentLinkId] = [...parentChildIds, linkId];
364 if (originalTrashed) {
366 recursivelyRestoreChildren(state, shareId, linkId, originalTrashed);
371 state[shareId].tree[parentLinkId] = [linkId];
376 if (stateUpdated && onAddOrUpdate) {
384 * getParentTrashed finds closest parent which is trashed and returns its
385 * trashed property, or returns null if link is not belonging under trashed
388 function getParentTrashed(state: LinksState, shareId: string, linkId: string): number | null {
390 const link = state[shareId].links[linkId];
394 if (link.encrypted.trashed) {
395 return link.encrypted.trashed;
397 linkId = link.encrypted.parentLinkId;
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.
407 function recursivelyTrashChildren(state: LinksState, shareId: string, linkId: string, trashed: number) {
408 recursivelyUpdateLinks(state, shareId, linkId, (link) => {
412 trashed: link.encrypted.trashed || trashed,
413 trashedByParent: true,
415 decrypted: link.decrypted
418 trashed: link.decrypted.trashed || trashed,
419 trashedByParent: true,
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
434 function recursivelyRestoreChildren(state: LinksState, shareId: string, linkId: string, originalTrashed: number) {
435 recursivelyUpdateLinks(state, shareId, linkId, (link) => {
436 if (link.encrypted.trashed !== originalTrashed) {
443 trashedByParent: false,
445 decrypted: link.decrypted
449 trashedByParent: false,
457 * recursivelyUpdateLinks recursively calls updateCallback for every cached
458 * child of the provided linkId in scope of shareId.
460 function recursivelyUpdateLinks(
464 updateCallback: (link: Link) => Link
466 state[shareId].tree[linkId]?.forEach((linkId) => {
467 const child = state[shareId].links[linkId];
471 state[shareId].links[linkId] = updateCallback(child);
472 recursivelyUpdateLinks(state, shareId, child.encrypted.linkId, updateCallback);
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.
483 return newSignatureIssues;
485 if (original.signatureIssues || newSignatureIssues) {
486 return { ...original.signatureIssues, ...newSignatureIssues };
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.
505 function getNewDecryptedLink(original: Link, newLink: Link): DecryptedLink | undefined {
506 if (newLink.decrypted) {
508 ...newLink.decrypted,
509 ...getDecryptedLinkComputedData(
511 newLink.decrypted.activeRevision?.id,
512 newLink.decrypted.shareUrl
516 if (original.decrypted) {
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(
526 newLink.encrypted.activeRevision?.id,
527 newLink.encrypted.shareUrl
529 isStale: !isDecryptedLinkSame(original.encrypted, newLink.encrypted),
536 * getDecryptedLinkComputedData returns locally computed data.
538 * - numAccesses from shareUrl,
540 * - and cachedThumbnailUrl.
542 function getDecryptedLinkComputedData(link?: DecryptedLink, newRevisionId?: string, newShareUrl?: LinkShareUrl) {
546 shareUrl: newShareUrl
549 numAccesses: getNewNumAccesses(newShareUrl, link),
552 isLocked: link.isLocked,
553 cachedThumbnailUrl: link.activeRevision?.id === newRevisionId ? link.cachedThumbnailUrl : undefined,
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;
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;
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) {
574 // In all other cases keep undefined. We just don't know.
578 export function setLock(
581 linkIdsOrTrash: string[] | 'trash',
584 if (!state[shareId]) {
588 if (Array.isArray(linkIdsOrTrash)) {
589 linkIdsOrTrash.forEach((linkId) => {
590 if (!state[shareId].links[linkId]?.decrypted) {
594 state[shareId].links[linkId].decrypted = {
595 ...(state[shareId].links[linkId].decrypted as DecryptedLink),
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),
613 export function setCachedThumbnailUrl(
617 cachedThumbnailUrl: string
619 if (!state[shareId]) {
623 const link = state[shareId].links[linkId];
624 if (!link?.decrypted) {
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);
645 throw new Error('Trying to use uninitialized LinksStateProvider');