1 import { useCallback, useEffect, useMemo, useState } from 'react';
3 import { c } from 'ttag';
5 import { useNotifications } from '@proton/components';
7 import type { DecryptedLink } from '../_links';
8 import { useLink, useLinksListing } from '../_links';
9 import { ShareType, useShare } from '../_shares';
10 import { useErrorHandler } from '../_utils';
11 import { useAbortSignal } from './utils';
12 import { useShareType } from './utils/useShareType';
14 export interface TreeItem {
21 interface TreeOptions {
23 rootExpanded?: boolean;
24 foldersOnly?: boolean;
27 export function useTreeForModals(shareId: string, options?: Omit<TreeOptions, 'rootLinkId'>) {
28 const shareType = useShareType(shareId);
29 const getRootItems = (tree: ReturnType<typeof useTree>): TreeItem[] => {
30 if (shareType === ShareType.device) {
31 return tree.rootFolder?.children ? tree.rootFolder?.children : [];
34 const isLoaded = tree.rootFolder?.isLoaded;
36 if (isLoaded && tree.rootFolder?.children.length === 0) {
37 // Avoid displaying root folder for empty My Files section not
38 // to have only one non-interactable tree element in the UI
42 return tree.rootFolder ? [tree.rootFolder] : [];
45 const tree = useTree(shareId, { ...options });
46 const isLoaded = tree.rootFolder?.isLoaded || false;
48 let items = getRootItems(tree);
58 * useFolderTree provides data for folder tree view of the provided share.
60 * @deprecated – if possible, reuse logic from useTreeForModals, if there's a need to
61 * exlude root link from the output. The reason this function exists is that Sidebar
62 * component has it's own implementation of formatting the tree. This function and
63 * `useTreeForModals` is an object of possible refactor – the goal of it is to create
64 * a unified mechanism to get tree data ready for presentation.
66 export function useFolderTree(shareId: string, options?: TreeOptions) {
67 return useTree(shareId, { ...options, foldersOnly: true });
71 * useTree provides data for complete tree view of the provided share.
73 export function useTree(shareId: string, { rootLinkId, rootExpanded, foldersOnly = false }: TreeOptions) {
74 const { createNotification } = useNotifications();
75 const { showErrorNotification } = useErrorHandler();
76 const { getShare } = useShare();
77 const { getLink } = useLink();
78 const { loadChildren, getCachedChildren } = useLinksListing();
80 const abortSignal = useAbortSignal([shareId, rootLinkId]);
81 const [rootFolder, setFolderTree] = useState<TreeItem>();
83 const getShareRootLinkId = async (abortSignal: AbortSignal, shareId: string) => {
84 const share = await getShare(abortSignal, shareId);
85 return share.rootLinkId;
88 // Reset the whole tree when share info changed.
90 const abortController = new AbortController();
91 (rootLinkId ? Promise.resolve(rootLinkId) : getShareRootLinkId(abortController.signal, shareId))
92 .then((rootLinkId) => getLink(abortController.signal, shareId, rootLinkId))
96 isExpanded: rootExpanded || false,
102 showErrorNotification(err, c('Notification').t`Root folder failed to be loaded`);
104 }, [shareId, rootLinkId, rootExpanded]);
106 const syncTreeWithCache = useCallback(
107 (item: TreeItem): TreeItem => {
108 // Sync with cache only expanded part of the tree so we don't have
109 // to keep in sync everything in the cache as that would need to
110 // make sure have everyting up to date and decrypted. If user don't
111 // need it, lets not waste valuable CPU time on it. But do it only
112 // for children - lets keep root folder always up to date, as we
113 // preload root everytime and the main expand button depends on it.
114 if (!item.isExpanded && item.link.parentLinkId) {
118 const { links: children } = getCachedChildren(abortSignal, shareId, item.link.linkId, foldersOnly);
122 const newIds = children.map(({ linkId }) => linkId);
123 const prevItems = item.isLoaded
125 .filter(({ link }) => newIds.includes(link.linkId))
126 .map((child): TreeItem => {
127 const item = children.find((item) => item.linkId === child.link.linkId);
128 if (item && item.name !== child.link.name) {
129 return { ...child, link: item };
135 const currentIds = item.children.map(({ link }) => link.linkId);
136 const newItems = children
137 .filter((item) => !currentIds.includes(item.linkId) && !item.trashed)
139 (item): TreeItem => ({
147 item.children = [...prevItems, ...newItems].map(syncTreeWithCache);
148 item.children.sort((a, b) => {
149 if (a.link.isFile !== b.link.isFile) {
150 return a.link.isFile < b.link.isFile ? -1 : 1;
152 return a.link.name.localeCompare(b.link.name, undefined, { numeric: true });
157 [shareId, foldersOnly, getCachedChildren]
160 const setLoadedFlag = (item: TreeItem, linkId: string) => {
161 if (item.link.linkId === linkId) {
162 item.isLoaded = true;
165 item.children = item.children.map((child) => setLoadedFlag(child, linkId));
169 const loadSubfolders = useCallback(
170 (abortSignal: AbortSignal, linkId: string) => {
171 loadChildren(abortSignal, shareId, linkId, foldersOnly)
173 setFolderTree((state) => {
177 state = setLoadedFlag(state, linkId);
178 return syncTreeWithCache(state);
181 .catch((err) => showErrorNotification(err, c('Notification').t`Subfolder failed to be loaded`));
183 [shareId, foldersOnly, loadChildren]
186 // Update local folder tree when cache has updated.
188 setFolderTree((state) => (state ? syncTreeWithCache(state) : undefined));
189 }, [syncTreeWithCache]);
191 // Load root childs automatically so we have anything to show right away.
193 if (!rootFolder || rootFolder.isLoaded) {
197 const abortController = new AbortController();
198 loadSubfolders(abortController.signal, rootFolder.link.linkId);
200 abortController.abort();
202 }, [!rootFolder || rootFolder.isLoaded, rootFolder?.link?.linkId]);
204 const setExpand = useCallback(
205 (linkId: string, getNewExpanded: (item: TreeItem) => boolean) => {
206 const updateExpand = (item: TreeItem) => {
207 if (item.link.linkId === linkId) {
208 if (!item.isExpanded && !item.isLoaded) {
209 loadSubfolders(new AbortController().signal, item.link.linkId);
211 item.isExpanded = getNewExpanded(item);
213 item.children = item.children.map(updateExpand);
216 setFolderTree((state) => (state ? updateExpand(state) : undefined));
221 const expand = useCallback((linkId: string) => setExpand(linkId, () => true), [setExpand]);
223 const toggleExpand = useCallback(
224 (linkId: string) => setExpand(linkId, ({ isExpanded }) => !isExpanded),
228 const deepestOpenedLevel = useMemo(() => getDeepestOpenedLevel(rootFolder), [rootFolder]);
231 if (deepestOpenedLevel === 42) {
234 text: 'Achievement unlocked: folder tree master 1',
237 }, [deepestOpenedLevel]);
247 function getDeepestOpenedLevel(item?: TreeItem, level = 0): number {
248 if (!item || !item.isExpanded || !item.children.length) {
251 const levels = item.children.map((child) => getDeepestOpenedLevel(child, level + 1));
252 return Math.max(...levels);