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';
13 SimpleSidebarListItemHeader,
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 };
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 } =
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;
83 const labelsUnread = !!mailboxCount?.find((labelCount) => {
84 return (labelCount?.LabelID && isCustomLabel(labelCount.LabelID, labels) && labelCount?.Unread) || 0 > 0;
90 folders.map((folder) => ({
92 Expanded: getItem(formatFolderID(folder.ID)) === 'false' ? 0 : 1,
98 const handleToggleFolder = useCallback(
99 (folder: Folder, expanded: boolean) => {
102 foldersUI.map((folderItem: Folder) => {
103 if (folderItem.ID === folder.ID) {
106 Expanded: expanded ? 1 : 0,
113 // Save expanded state locally
114 setItem(formatFolderID(folder.ID), `${expanded}`);
119 const treeviewReducer = (acc: string[], folder: FolderWithSubFolders) => {
122 if (folder.Expanded) {
123 folder.subfolders?.forEach((folder) => treeviewReducer(acc, folder));
129 const reduceFolderTreeview = useMemo(
130 () => foldersTreeview.reduce((acc: string[], folder: FolderWithSubFolders) => treeviewReducer(acc, folder), []),
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) {
143 scrollIntoView(element, { block: 'nearest' });
146 const counterMap = useDeepMemo(() => {
147 if (!mailSettings || !labels || !folders || !conversationCounts || !messageCounts) {
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;
157 return unreadCounterMap;
158 }, [mailSettings, labels, folders, conversationCounts, messageCounts, location]);
160 const totalMessagesMap = useDeepMemo(() => {
161 if (!mailSettings || !labels || !folders || !conversationCounts || !messageCounts) {
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;
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) {
180 if (systemFolder.ID === MAILBOX_LABEL_IDS.ALL_SENT) {
181 return hasBit(mailSettings.ShowMoved, SHOW_MOVED.SENT);
183 if (systemFolder.ID === MAILBOX_LABEL_IDS.SENT) {
184 return !hasBit(mailSettings.ShowMoved, SHOW_MOVED.SENT);
186 if (systemFolder.ID === MAILBOX_LABEL_IDS.ALL_DRAFTS) {
187 return hasBit(mailSettings.ShowMoved, SHOW_MOVED.DRAFTS);
189 if (systemFolder.ID === MAILBOX_LABEL_IDS.DRAFTS) {
190 return !hasBit(mailSettings.ShowMoved, SHOW_MOVED.DRAFTS);
192 if (systemFolder.ID === MAILBOX_LABEL_IDS.SCHEDULED) {
193 return showScheduled;
195 if (systemFolder.ID === MAILBOX_LABEL_IDS.SNOOZED) {
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);
212 bottomSystemFolders.push(humanLabelID);
217 topSystemFolders?.length && topSystemFolders,
219 displayMoreItems && bottomSystemFolders?.length && bottomSystemFolders,
221 displayFolders && foldersArray,
223 displayLabels && labelsArray,
228 reduceFolderTreeview,
231 visibleSystemFolders,
239 const shortcutHandlers: HotkeyTuple[] = [
244 const currentIndex = sidebarListItems.indexOf(focusedItem || '');
245 const previousIndex = currentIndex !== -1 ? Math.max(0, currentIndex - 1) : sidebarListItems.length - 1;
246 updateFocusItem(sidebarListItems[previousIndex]);
253 updateFocusItem(sidebarListItems[0]);
260 const currentIndex = sidebarListItems.indexOf(focusedItem || '');
261 const nextIndex = currentIndex !== -1 ? Math.min(sidebarListItems.length - 1, currentIndex + 1) : 0;
262 updateFocusItem(sidebarListItems[nextIndex]);
266 ['Meta', 'ArrowDown'],
269 updateFocusItem(sidebarListItems[sidebarListItems.length - 1]);
276 (document.querySelector(
277 '[data-shortcut-target="item-container"][data-shortcut-target-selected="true"]'
279 (document.querySelector('[data-shortcut-target="item-container"]') as HTMLElement);
285 useHotkeys(sidebarRef, shortcutHandlers);
288 <LabelActionsContextProvider>
289 <div ref={sidebarRef} tabIndex={-1} className="outline-none grow-2">
291 <MailSidebarSystemFolders
292 counterMap={counterMap}
293 currentLabelID={currentLabelID}
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}
310 originalPlacement="right"
311 title={c('Action').t`Expand navigation bar to see folders`}
314 onClick={() => onClickExpandNav?.(SOURCE_EVENT.BUTTON_FOLDERS)}
315 className="flex items-center relative navigation-link-header-group-link mx-auto w-full"
319 alt={c('Action').t`Expand navigation bar to see folders`}
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`}
336 <SimpleSidebarListItemHeader
337 toggle={displayFolders}
338 onToggle={(display: boolean) => toggleFolders(display)}
339 text={c('Link').t`Folders`}
340 title={c('Link').t`Folders`}
342 onFocus={setFocusedItem}
343 right={<MailSidebarListActions type="folder" items={folders || []} />}
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}
364 originalPlacement="right"
365 title={c('Action').t`Expand navigation bar to see labels`}
368 onClick={() => onClickExpandNav?.(SOURCE_EVENT.BUTTON_LABELS)}
369 className="flex items-center relative navigation-link-header-group-link mx-auto w-full"
373 alt={c('Action').t`Expand navigation bar to see labels`}
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`}
390 <SimpleSidebarListItemHeader
391 toggle={displayLabels}
392 onToggle={(display: boolean) => toggleLabels(display)}
393 text={c('Link').t`Labels`}
394 title={c('Link').t`Labels`}
396 onFocus={setFocusedItem}
397 right={<MailSidebarListActions type="label" items={labels || []} />}
402 currentLabelID={currentLabelID}
403 counterMap={counterMap}
404 labels={labels || []}
405 updateFocusItem={updateFocusItem}
406 applyLabels={applyLabels}
407 moveToFolder={moveToFolder}
420 {applyLabelsToAllModal}
421 </LabelActionsContextProvider>
425 export default MailSidebarList;