Merge branch 'INDA-330-pii-update' into 'main'
[ProtonMail-WebClient.git] / applications / mail / src / app / components / sidebar / MailSidebarList.tsx
blobd5285b88b9ae4c38e45bfa63322fef1d714a65ef
1 import type { ReactNode } from 'react';
2 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3 import { useLocation } from 'react-router-dom';
5 import { c } from 'ttag';
7 import { useUser } from '@proton/account/user/hooks';
8 import type { HotkeyTuple } from '@proton/components';
9 import {
10     Icon,
11     SidebarList,
12     SidebarListItem,
13     SimpleSidebarListItemHeader,
14     Tooltip,
15     useHotkeys,
16     useLocalState,
17 } from '@proton/components';
18 import { useFolders, useLabels, useSystemFolders } from '@proton/mail';
19 import { useConversationCounts } from '@proton/mail/counts/conversationCounts';
20 import { useMessageCounts } from '@proton/mail/counts/messageCounts';
21 import { MAILBOX_LABEL_IDS } from '@proton/shared/lib/constants';
22 import { hasBit } from '@proton/shared/lib/helpers/bitset';
23 import { SOURCE_EVENT } from '@proton/shared/lib/helpers/collapsibleSidebar';
24 import { scrollIntoView } from '@proton/shared/lib/helpers/dom';
25 import { buildTreeview } from '@proton/shared/lib/helpers/folder';
26 import { getItem, setItem } from '@proton/shared/lib/helpers/storage';
27 import type { Folder, FolderWithSubFolders } from '@proton/shared/lib/interfaces/Folder';
28 import { SHOW_MOVED, VIEW_MODE } from '@proton/shared/lib/mail/mailSettings';
29 import isTruthy from '@proton/utils/isTruthy';
31 import { isCustomFolder, isCustomLabel } from 'proton-mail/helpers/labels';
32 import useMailModel from 'proton-mail/hooks/useMailModel';
34 import { LABEL_IDS_TO_HUMAN } from '../../constants';
35 import { getCounterMap } from '../../helpers/elements';
36 import { useApplyLabels } from '../../hooks/actions/label/useApplyLabels';
37 import { useMoveToFolder } from '../../hooks/actions/move/useMoveToFolder';
38 import { useDeepMemo } from '../../hooks/useDeepMemo';
39 import { LabelActionsContextProvider } from './EditLabelContext';
40 import MailSidebarListActions from './MailSidebarListActions';
41 import MailSidebarSystemFolders from './MailSidebarSystemFolders';
42 import SidebarFolders from './SidebarFolders';
43 import SidebarLabels from './SidebarLabels';
45 export type UnreadCounts = { [labelID: string]: number | undefined };
47 interface Props {
48     labelID: string;
49     postItems: ReactNode;
50     collapsed?: boolean;
51     onClickExpandNav?: (sourceEvent: SOURCE_EVENT) => void;
54 const formatFolderID = (folderID: string): string => `folder_expanded_state_${folderID}`;
56 const MailSidebarList = ({ labelID: currentLabelID, postItems, collapsed = false, onClickExpandNav }: Props) => {
57     const location = useLocation();
58     const [user] = useUser();
59     const [conversationCounts] = useConversationCounts();
60     const [messageCounts] = useMessageCounts();
61     const mailSettings = useMailModel('MailSettings');
62     const [systemFolders] = useSystemFolders();
63     const [labels] = useLabels();
64     const [folders, loadingFolders] = useFolders();
65     const numFolders = folders?.length || 0;
66     const numLabels = labels?.length || 0;
67     // Use user.ID or item because in the tests user ID is undefined
68     const [displayFolders, toggleFolders] = useLocalState(numFolders > 0, `${user.ID || 'item'}-display-folders`);
69     const [displayLabels, toggleLabels] = useLocalState(numLabels > 0, `${user.ID || 'item'}-display-labels`);
70     const [displayMoreItems, toggleDisplayMoreItems] = useLocalState(false, `${user.ID || 'item'}-display-more-items`);
71     const sidebarRef = useRef<HTMLDivElement>(null);
72     const [focusedItem, setFocusedItem] = useState<string | null>(null);
73     const [foldersUI, setFoldersUI] = useState<Folder[]>([]);
74     const foldersTreeview = useMemo(() => buildTreeview(foldersUI), [foldersUI]);
75     const { applyLabels, applyLabelsToAllModal } = useApplyLabels();
76     const { moveToFolder, moveScheduledModal, moveSnoozedModal, moveToSpamModal, selectAllMoveModal } =
77         useMoveToFolder();
78     const mailboxCount = mailSettings.ViewMode === VIEW_MODE.GROUP ? conversationCounts : messageCounts;
80     const foldersUnread = !!mailboxCount?.find((labelCount) => {
81         return (labelCount?.LabelID && isCustomFolder(labelCount?.LabelID, folders) && labelCount?.Unread) || 0 > 0;
82     });
83     const labelsUnread = !!mailboxCount?.find((labelCount) => {
84         return (labelCount?.LabelID && isCustomLabel(labelCount.LabelID, labels) && labelCount?.Unread) || 0 > 0;
85     });
87     useEffect(() => {
88         if (folders) {
89             setFoldersUI(
90                 folders.map((folder) => ({
91                     ...folder,
92                     Expanded: getItem(formatFolderID(folder.ID)) === 'false' ? 0 : 1,
93                 }))
94             );
95         }
96     }, [folders]);
98     const handleToggleFolder = useCallback(
99         (folder: Folder, expanded: boolean) => {
100             // Update view
101             setFoldersUI(
102                 foldersUI.map((folderItem: Folder) => {
103                     if (folderItem.ID === folder.ID) {
104                         return {
105                             ...folderItem,
106                             Expanded: expanded ? 1 : 0,
107                         };
108                     }
109                     return folderItem;
110                 })
111             );
113             // Save expanded state locally
114             setItem(formatFolderID(folder.ID), `${expanded}`);
115         },
116         [foldersUI]
117     );
119     const treeviewReducer = (acc: string[], folder: FolderWithSubFolders) => {
120         acc.push(folder.ID);
122         if (folder.Expanded) {
123             folder.subfolders?.forEach((folder) => treeviewReducer(acc, folder));
124         }
126         return acc;
127     };
129     const reduceFolderTreeview = useMemo(
130         () => foldersTreeview.reduce((acc: string[], folder: FolderWithSubFolders) => treeviewReducer(acc, folder), []),
131         [foldersTreeview]
132     );
134     const updateFocusItem = useCallback((item: string) => {
135         setFocusedItem(item);
136         const element = sidebarRef?.current?.querySelector(`[data-shortcut-target~="${item}"]`) as HTMLElement;
137         // If the active element is already contained inside the item, don't re-focus the parent. This can happen when there's a button
138         // inside the item which we want to take focus instead of the parent.
139         if (element?.contains(document.activeElement) || document.activeElement === element) {
140             return;
141         }
142         element?.focus();
143         scrollIntoView(element, { block: 'nearest' });
144     }, []);
146     const counterMap = useDeepMemo(() => {
147         if (!mailSettings || !labels || !folders || !conversationCounts || !messageCounts) {
148             return {};
149         }
151         const all = [...labels, ...folders];
152         const labelCounterMap = getCounterMap(all, conversationCounts, messageCounts, mailSettings);
153         const unreadCounterMap = Object.entries(labelCounterMap).reduce<UnreadCounts>((acc, [id, labelCount]) => {
154             acc[id] = labelCount?.Unread;
155             return acc;
156         }, {});
157         return unreadCounterMap;
158     }, [mailSettings, labels, folders, conversationCounts, messageCounts, location]);
160     const totalMessagesMap = useDeepMemo(() => {
161         if (!mailSettings || !labels || !folders || !conversationCounts || !messageCounts) {
162             return {};
163         }
165         const all = [...labels, ...folders];
166         const labelCounterMap = getCounterMap(all, conversationCounts, messageCounts, mailSettings);
167         const unreadCounterMap = Object.entries(labelCounterMap).reduce<UnreadCounts>((acc, [id, labelCount]) => {
168             acc[id] = labelCount?.Total;
169             return acc;
170         }, {});
171         return unreadCounterMap;
172     }, [messageCounts, conversationCounts, labels, folders, mailSettings, location]);
174     const showScheduled = (totalMessagesMap[MAILBOX_LABEL_IDS.SCHEDULED] || 0) > 0;
175     const showSnoozed = (totalMessagesMap[MAILBOX_LABEL_IDS.SNOOZED] || 0) > 0;
176     const visibleSystemFolders = systemFolders?.filter((systemFolder) => {
177         if (systemFolder.ID === MAILBOX_LABEL_IDS.OUTBOX) {
178             return false;
179         }
180         if (systemFolder.ID === MAILBOX_LABEL_IDS.ALL_SENT) {
181             return hasBit(mailSettings.ShowMoved, SHOW_MOVED.SENT);
182         }
183         if (systemFolder.ID === MAILBOX_LABEL_IDS.SENT) {
184             return !hasBit(mailSettings.ShowMoved, SHOW_MOVED.SENT);
185         }
186         if (systemFolder.ID === MAILBOX_LABEL_IDS.ALL_DRAFTS) {
187             return hasBit(mailSettings.ShowMoved, SHOW_MOVED.DRAFTS);
188         }
189         if (systemFolder.ID === MAILBOX_LABEL_IDS.DRAFTS) {
190             return !hasBit(mailSettings.ShowMoved, SHOW_MOVED.DRAFTS);
191         }
192         if (systemFolder.ID === MAILBOX_LABEL_IDS.SCHEDULED) {
193             return showScheduled;
194         }
195         if (systemFolder.ID === MAILBOX_LABEL_IDS.SNOOZED) {
196             return showSnoozed;
197         }
198         return true;
199     });
200     const sidebarListItems = useMemo(() => {
201         const foldersArray = folders?.length ? reduceFolderTreeview : ['add-folder'];
202         const labelsArray = labels?.length ? labels.map((f) => f.ID) : ['add-label'];
203         const topSystemFolders: string[] = [];
204         const bottomSystemFolders: string[] = [];
206         visibleSystemFolders?.forEach((folder) => {
207             const humanLabelID = LABEL_IDS_TO_HUMAN[folder.ID as MAILBOX_LABEL_IDS];
209             if (folder.Display) {
210                 topSystemFolders.push(humanLabelID);
211             } else {
212                 bottomSystemFolders.push(humanLabelID);
213             }
214         });
216         return [
217             topSystemFolders?.length && topSystemFolders,
218             'toggle-more-items',
219             displayMoreItems && bottomSystemFolders?.length && bottomSystemFolders,
220             'toggle-folders',
221             displayFolders && foldersArray,
222             'toggle-labels',
223             displayLabels && labelsArray,
224         ]
225             .flat(1)
226             .filter(isTruthy);
227     }, [
228         reduceFolderTreeview,
229         folders,
230         labels,
231         visibleSystemFolders,
232         showScheduled,
233         showSnoozed,
234         displayFolders,
235         displayLabels,
236         displayMoreItems,
237     ]);
239     const shortcutHandlers: HotkeyTuple[] = [
240         [
241             'ArrowUp',
242             (e) => {
243                 e.preventDefault();
244                 const currentIndex = sidebarListItems.indexOf(focusedItem || '');
245                 const previousIndex = currentIndex !== -1 ? Math.max(0, currentIndex - 1) : sidebarListItems.length - 1;
246                 updateFocusItem(sidebarListItems[previousIndex]);
247             },
248         ],
249         [
250             ['Meta', 'ArrowUp'],
251             (e) => {
252                 e.preventDefault();
253                 updateFocusItem(sidebarListItems[0]);
254             },
255         ],
256         [
257             'ArrowDown',
258             (e) => {
259                 e.preventDefault();
260                 const currentIndex = sidebarListItems.indexOf(focusedItem || '');
261                 const nextIndex = currentIndex !== -1 ? Math.min(sidebarListItems.length - 1, currentIndex + 1) : 0;
262                 updateFocusItem(sidebarListItems[nextIndex]);
263             },
264         ],
265         [
266             ['Meta', 'ArrowDown'],
267             (e) => {
268                 e.preventDefault();
269                 updateFocusItem(sidebarListItems[sidebarListItems.length - 1]);
270             },
271         ],
272         [
273             'ArrowRight',
274             () => {
275                 const element =
276                     (document.querySelector(
277                         '[data-shortcut-target="item-container"][data-shortcut-target-selected="true"]'
278                     ) as HTMLElement) ||
279                     (document.querySelector('[data-shortcut-target="item-container"]') as HTMLElement);
280                 element?.focus();
281             },
282         ],
283     ];
285     useHotkeys(sidebarRef, shortcutHandlers);
287     return (
288         <LabelActionsContextProvider>
289             <div ref={sidebarRef} tabIndex={-1} className="outline-none grow-2">
290                 <SidebarList>
291                     <MailSidebarSystemFolders
292                         counterMap={counterMap}
293                         currentLabelID={currentLabelID}
294                         location={location}
295                         mailSettings={mailSettings}
296                         setFocusedItem={setFocusedItem}
297                         totalMessagesMap={totalMessagesMap}
298                         displayMoreItems={displayMoreItems}
299                         showScheduled={showScheduled}
300                         showSnoozed={showSnoozed}
301                         onToggleMoreItems={toggleDisplayMoreItems}
302                         collapsed={collapsed}
303                         applyLabels={applyLabels}
304                         moveToFolder={moveToFolder}
305                     />
307                     {collapsed ? (
308                         <SidebarListItem>
309                             <Tooltip
310                                 originalPlacement="right"
311                                 title={c('Action').t`Expand navigation bar to see folders`}
312                             >
313                                 <button
314                                     onClick={() => onClickExpandNav?.(SOURCE_EVENT.BUTTON_FOLDERS)}
315                                     className="flex items-center relative navigation-link-header-group-link mx-auto w-full"
316                                 >
317                                     <Icon
318                                         name="folders"
319                                         alt={c('Action').t`Expand navigation bar to see folders`}
320                                         className="mx-auto"
321                                     />
322                                     {foldersUnread && (
323                                         <span className="navigation-counter-item shrink-0">
324                                             <span className="sr-only">
325                                                 {mailSettings.ViewMode === VIEW_MODE.GROUP
326                                                     ? c('Info').t`Unread conversations`
327                                                     : c('Info').t`Unread messages`}
328                                             </span>
329                                         </span>
330                                     )}
331                                 </button>
332                             </Tooltip>
333                         </SidebarListItem>
334                     ) : (
335                         <>
336                             <SimpleSidebarListItemHeader
337                                 toggle={displayFolders}
338                                 onToggle={(display: boolean) => toggleFolders(display)}
339                                 text={c('Link').t`Folders`}
340                                 title={c('Link').t`Folders`}
341                                 id="toggle-folders"
342                                 onFocus={setFocusedItem}
343                                 right={<MailSidebarListActions type="folder" items={folders || []} />}
344                                 spaceAbove
345                             />
346                             {displayFolders && (
347                                 <SidebarFolders
348                                     currentLabelID={currentLabelID}
349                                     counterMap={counterMap}
350                                     folders={folders || []}
351                                     loadingFolders={loadingFolders}
352                                     updateFocusItem={updateFocusItem}
353                                     handleToggleFolder={handleToggleFolder}
354                                     foldersTreeview={foldersTreeview}
355                                     applyLabels={applyLabels}
356                                     moveToFolder={moveToFolder}
357                                 />
358                             )}
359                         </>
360                     )}
361                     {collapsed ? (
362                         <SidebarListItem>
363                             <Tooltip
364                                 originalPlacement="right"
365                                 title={c('Action').t`Expand navigation bar to see labels`}
366                             >
367                                 <button
368                                     onClick={() => onClickExpandNav?.(SOURCE_EVENT.BUTTON_LABELS)}
369                                     className="flex items-center relative navigation-link-header-group-link mx-auto w-full"
370                                 >
371                                     <Icon
372                                         name="tags"
373                                         alt={c('Action').t`Expand navigation bar to see labels`}
374                                         className="mx-auto"
375                                     />
376                                     {labelsUnread && (
377                                         <span className="navigation-counter-item shrink-0">
378                                             <span className="sr-only">
379                                                 {mailSettings.ViewMode === VIEW_MODE.GROUP
380                                                     ? c('Info').t`Unread conversations`
381                                                     : c('Info').t`Unread messages`}
382                                             </span>
383                                         </span>
384                                     )}
385                                 </button>
386                             </Tooltip>
387                         </SidebarListItem>
388                     ) : (
389                         <>
390                             <SimpleSidebarListItemHeader
391                                 toggle={displayLabels}
392                                 onToggle={(display: boolean) => toggleLabels(display)}
393                                 text={c('Link').t`Labels`}
394                                 title={c('Link').t`Labels`}
395                                 id="toggle-labels"
396                                 onFocus={setFocusedItem}
397                                 right={<MailSidebarListActions type="label" items={labels || []} />}
398                                 spaceAbove
399                             />
400                             {displayLabels && (
401                                 <SidebarLabels
402                                     currentLabelID={currentLabelID}
403                                     counterMap={counterMap}
404                                     labels={labels || []}
405                                     updateFocusItem={updateFocusItem}
406                                     applyLabels={applyLabels}
407                                     moveToFolder={moveToFolder}
408                                 />
409                             )}
410                         </>
411                     )}
413                     {postItems}
414                 </SidebarList>
415             </div>
416             {moveScheduledModal}
417             {moveSnoozedModal}
418             {moveToSpamModal}
419             {selectAllMoveModal}
420             {applyLabelsToAllModal}
421         </LabelActionsContextProvider>
422     );
425 export default MailSidebarList;