"use client"; import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSidebarSections } from "@/components/plugins/plugin-sidebar-sections"; import { Button } from "@/components/ui/button"; import { Inbox, Send, File, Star, Trash2, Archive, Ban, ChevronsLeft, ChevronsRight, ChevronRight, ChevronDown, ChevronUp, Folder, FolderOpen, User, Users, Palmtree, Settings, X, RotateCcw, Tag, FlaskConical, PlayCircle, Loader2, AlertTriangle, NotebookPen, Pencil, Plus, CalendarClock, BellOff, Mails, MailOpen, MoreHorizontal, } from "@/components/icons"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { buildKeywordTree, countKeywordNodes, filterKeywordTree, hasChildKeywords, type KeywordNode, } from "@/lib/keyword-nesting"; import { useShortenedText } from "@/hooks/use-shortened-text"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu"; import { useAccountStore } from '@/stores/account-store'; import { useShallow } from "zustand/react/shallow"; import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types'; import type { UnifiedMailboxRole } from '@/lib/jmap/types'; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; import { MAILBOX_DRAG_MIME } from "@/components/pro/pro-shell-drop"; import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { unifiedLoadProgress } from "@/lib/unified-mailbox"; import { useVacationStore } from "@/stores/vacation-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; import { AccountSwitcher } from "./account-switcher"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } from "@/lib/deep-links"; import { useTour } from "@/components/tour/tour-provider"; import { toUnicodeEmail } from "@/lib/idn"; import { hiddenSharedMailIds, isSharedMailAccountVisible, setSharedMailAccountHidden } from "@/lib/shared-mail-visibility"; import { isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted, type NotificationMutes, } from "@/lib/notification-mutes"; import { serverSupportsEmailPush } from "@/lib/web-push"; import { resolveFolderCounts } from "@/lib/folder-counts"; interface SidebarProps { mailboxes: Mailbox[]; selectedMailbox?: string; selectedKeyword?: string | null; onMailboxSelect?: (mailboxId: string) => void; onTagSelect?: (keywordId: string | null) => void; onCompose?: () => void; /** * Mountain View, desktop only: the rail grows a Compose pill at the top and * drops its own header, because the global top bar already carries the * collapse control and the account switcher. */ mvShell?: boolean; onSidebarClose?: () => void; onUnreadFilterClick?: (mailboxId: string) => void; onMarkFolderRead?: (mailboxId: string) => void; onMarkFolderTreeRead?: (mailboxId: string) => void; onMarkAllFoldersRead?: () => void; onEmptyFolder?: (mailboxId: string) => void; onCreateSubfolder?: (parentId: string) => void; onCreateFolder?: (accountId?: string) => void; onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; onShareFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; scheduledTotal?: number; /** Pending scheduled-send counts per JMAP account, for shared-account rows. */ scheduledTotalByAccount?: Record; /** JMAP account the scheduled view is currently narrowed to, if any. The * store keeps one virtual scheduled mailbox id, so the active shared row is * identified by this scope rather than by selectedMailbox alone. */ scheduledAccountScope?: string | null; showScheduledMailbox?: boolean; /** True when the unified view spans multiple login accounts (cross-account). * Drives the section header: "All accounts" when true, else "Unified Mailbox". */ crossAccountActive?: boolean; /** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */ showCrossUnread?: boolean; showCrossStarred?: boolean; /** Mountain View: open Starred from its own row under the Inbox. */ onOpenStarred?: () => void; showCrossAll?: boolean; /** Unread total across all cross-view folders (badge for unread/all). */ crossUnreadCount?: number; className?: string; /** * Multi-account (Pro) mode props. When `multiAccountMode` is true, the * sidebar renders a per-connected-account group instead of a single * folders section - Thunderbird-style. `accountMailboxes` provides the * mailbox list for non-active accounts (the active account still flows * through the `mailboxes` prop). `viewingAccountId` highlights which * account's folder is currently selected (null = active account). * `onAccountMailboxSelect` fires with the owning accountId when the user * picks a folder; callers translate that into `selectAccountMailbox`. */ multiAccountMode?: boolean; accountMailboxes?: Record; viewingAccountId?: string | null; onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void; } const ROW_PX_BASE = 8; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; // Icons and colours follow the JMAP role only. const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => { if (id?.startsWith('shared-account-')) { return User; } switch (role) { case "inbox": return Inbox; case "sent": return Send; case "drafts": return File; case "trash": return Trash2; case "junk": return Ban; case "archive": return Archive; case "shared": return Users; case "important": return AlertTriangle; case "memos": return NotebookPen; case "scheduled": return CalendarClock; case "snoozed": return BellOff; case "flagged": return Star; } if (hasChildren) { return isExpanded ? FolderOpen : Folder; } return Folder; }; const ROLE_ICON_COLOR: Record = { inbox: "text-blue-600/80 dark:text-blue-400/80", sent: "text-emerald-600/80 dark:text-emerald-400/80", drafts: "text-violet-600/80 dark:text-violet-400/80", trash: "text-muted-foreground", junk: "text-red-600/80 dark:text-red-400/80", archive: "text-amber-600/80 dark:text-amber-400/80", shared: "text-cyan-600/80 dark:text-cyan-400/80", important: "text-orange-600/80 dark:text-orange-400/80", memos: "text-yellow-600/80 dark:text-yellow-400/80", scheduled: "text-sky-600/80 dark:text-sky-400/80", snoozed: "text-slate-500/80 dark:text-slate-400/80", }; function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean, roleKey?: string) { const base = "w-4 h-4 flex-shrink-0 transition-colors"; if (isVirtual) return cn(base, "text-muted-foreground"); if (colorful && roleKey && ROLE_ICON_COLOR[roleKey]) { return cn(base, ROLE_ICON_COLOR[roleKey]); } return cn(base, isSelected ? "text-foreground" : "text-foreground/80"); } function SidebarRowCounts({ unread, total, role, mailboxId, onUnreadClick, }: { unread?: number; total?: number; role?: string | null; mailboxId?: string; isSelected: boolean; onUnreadClick?: () => void; }) { const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount); const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view'); // Per-folder override from the folder settings (#982). const countMode = useSettingsStore(s => (mailboxId ? s.folderCountModes?.[mailboxId] : undefined)); const counts = resolveFolderCounts({ unread, total, role, mode: countMode, showTotalGlobally: showFolderTotalCount, mountainView, }); // Mountain View counts one thing per row: what is unread, or for Drafts, // how many there are, since a draft is never unread. if (mountainView) { const showsTotal = counts.total > 0; const count = showsTotal ? counts.total : counts.unread; if (count === 0) return null; return ( {count.toLocaleString()} ); } const unreadCount = counts.unread; const totalCount = counts.total; if (unreadCount === 0 && totalCount === 0) return null; const unreadClass = "text-xs font-semibold tabular-nums text-foreground"; const totalClass = "text-xs tabular-nums text-muted-foreground"; const unreadNode = unreadCount > 0 ? ( onUnreadClick ? ( { e.stopPropagation(); onUnreadClick(); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); onUnreadClick(); } }} className={cn(unreadClass, "cursor-pointer hover:underline")} title={`${unreadCount} unread`} > {unreadCount} ) : ( {unreadCount} ) ) : null; return ( 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`} data-testid="folder-counts" data-unread={unreadCount} data-total={totalCount} > {unreadNode} {unreadCount > 0 && totalCount > 0 && ( / )} {totalCount > 0 && {totalCount}} ); } interface SidebarRowProps { icon: ReactNode; label: string; /** Progressively shorter renderings of `label`, longest first. The widest one * that fits the row is shown; without this the full label is used. */ labelCandidates?: string[]; depth?: number; isSelected?: boolean; isVirtual?: boolean; unread?: number; total?: number; onClick?: () => void; hasChildren?: boolean; isExpanded?: boolean; onExpandToggle?: () => void; onUnreadClick?: () => void; isCollapsed: boolean; dropHandlers?: Record; isValidDropTarget?: boolean; isInvalidDropTarget?: boolean; onContextMenu?: (e: React.MouseEvent) => void; /** New mail here does not notify (#1169). */ muted?: boolean; /** Stable identifiers for integration tests (not user-visible). */ testRole?: string | null; testName?: string; testMailboxId?: string; testShared?: boolean; } function SidebarRow({ icon, label, labelCandidates, depth = 0, isSelected = false, isVirtual = false, unread, total, onClick, hasChildren = false, isExpanded = false, onExpandToggle, onUnreadClick, isCollapsed, dropHandlers, isValidDropTarget, isInvalidDropTarget, onContextMenu, muted = false, testRole, testName, testMailboxId, testShared, }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]); return (
0 && testRole !== 'drafts' ? '' : undefined} style={{ paddingBlock: 'var(--density-sidebar-py)' }} className={cn( "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150", isCollapsed ? "justify-center px-1" : "pe-2", isVirtual ? "text-muted-foreground" : isSelected ? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary" : "hover:bg-muted/50 text-foreground border-s-2 border-transparent", isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset", isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50" )} > {!isCollapsed && (
{hasChildren && onExpandToggle ? ( ) : (
)}
)}
); } /** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */ const isSystemFolder = (node: MailboxNode) => !!node.role; /** Key of the single-account "Your folders" section in the collapsed set. */ const OWN_FOLDERS_KEY = '__own__'; function SidebarSectionHeader({ label, expanded, onToggle, onSettings, settingsTitle, isCollapsed, first, icon, sub, testId, onContextMenu, status, onAdd, addTitle, }: { label: string; expanded: boolean; onToggle: () => void; onSettings?: () => void; settingsTitle?: string; onAdd?: () => void; addTitle?: string; isCollapsed: boolean; first?: boolean; icon?: ReactNode; sub?: boolean; testId?: string; onContextMenu?: (event: React.MouseEvent) => void; /** A short note after the label, e.g. how many accounts have loaded. */ status?: ReactNode; }) { if (isCollapsed) { return first ? null :
; } const paddingY = sub ? "pt-2" : first ? "pt-3" : "pt-5"; const paddingX = sub ? "px-4" : "px-3"; const textClass = sub ? "text-xs font-semibold text-muted-foreground truncate" : "text-sm font-semibold text-foreground truncate"; return ( ); } /** Whether new mail in this folder of the active login stays silent (#1169). */ function useMutedMailbox(node: MailboxNode | null): boolean { const client = useAuthStore((s) => s.client); return useSettingsStore((s) => { if (!node || !client) return false; return isMailboxMuted(s.notificationMutes, node.accountId || client.getAccountId(), node.originalId || node.id); }); } function MailboxTreeItem({ node, selectedMailbox, expandedFolders, onMailboxSelect, onToggleExpand, isCollapsed, onUnreadFilterClick, colorful, onContextMenu, dragAccountId = null, }: { node: MailboxNode; selectedMailbox: string; expandedFolders: Set; onMailboxSelect?: (id: string) => void; onToggleExpand: (id: string) => void; isCollapsed: boolean; onUnreadFilterClick?: (mailboxId: string) => void; colorful: boolean; onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void; /** Connected-account id this folder is listed under; null = active account. * Travels in the folder-drag payload so a folder tab fetches through the * right client, and tells a message drop which login the folder is in. */ dragAccountId?: string | null; }) { const tNotifications = useTranslations('notifications'); const tSidebar = useTranslations('sidebar'); const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); const Icon = getIconForMailbox(node.role, hasChildren, isExpanded, node.id); const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); // Only for the active login's folders: its client tells a missing // accountId (own mailbox) apart. const muted = useMutedMailbox(isVirtualNode || dragAccountId ? null : node); const isEmbedded = useIsEmbedded(); // Pro shell only: folder rows are drag sources so a folder can be dropped // onto a tab strip / pane and open as its own tab. Native HTML5 DnD - the // Pro shell's drop targets live outside any dnd-kit context. const folderDragHandlers: Record | undefined = isEmbedded && !isVirtualNode ? { draggable: true, onDragStart: (e: React.DragEvent) => { e.dataTransfer.setData( MAILBOX_DRAG_MIME, JSON.stringify({ mailboxId: node.id, name: label, accountId: dragAccountId }), ); e.dataTransfer.effectAllowed = 'copy'; }, } : undefined; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ mailbox: node, accountId: dragAccountId ?? undefined, onSuccess: (count, mailboxName) => { if (count === 1) { toast.success( tNotifications('email_moved'), tNotifications('moved_to_mailbox', { mailbox: mailboxName }) ); } else { toast.success( tNotifications('emails_moved', { count }), tNotifications('moved_to_mailbox', { mailbox: mailboxName }) ); } }, onError: () => { toast.error(tNotifications('move_failed'), tNotifications('move_error')); }, }); return ( <> } label={label} testRole={node.role} testName={node.name} testMailboxId={node.id} testShared={node.isShared} depth={node.depth} isSelected={isSelected} isVirtual={isVirtualNode} unread={node.unreadEmails} total={node.totalEmails} onClick={() => onMailboxSelect?.(node.id)} hasChildren={hasChildren} isExpanded={isExpanded} onExpandToggle={() => onToggleExpand(node.id)} onUnreadClick={() => onUnreadFilterClick?.(node.id)} isCollapsed={isCollapsed} dropHandlers={ folderDragHandlers || globalDragging ? { ...(folderDragHandlers ?? {}), ...(globalDragging ? (dropHandlers as Record) : {}), } : undefined } isValidDropTarget={isValidDropTarget} isInvalidDropTarget={isInvalidDropTarget} onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined} muted={muted} /> {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( ))} ); } function ShowAllTagsRow({ hiddenCount, showAll, onToggle, isCollapsed, }: { hiddenCount: number; showAll: boolean; onToggle: () => void; isCollapsed: boolean; }) { const t = useTranslations('sidebar'); return ( } label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })} depth={0} onClick={onToggle} isCollapsed={isCollapsed} /> ); } function TagItem({ node, selectedKeyword, expandedTags, isCollapsed, onTagSelect, onToggleExpand, tagCounts, colorful, }: { node: KeywordNode; selectedKeyword: string | null; expandedTags: Set; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; onToggleExpand: (keywordId: string) => void; tagCounts: Record; colorful: boolean; }) { const t = useTranslations('notifications'); const { tagNameCandidates, tagColor } = useKeywordFormat(); const palette = tagColor(node.id); const hasChildren = node.children.length > 0; const isExpanded = expandedTags.has(node.id); const isSelected = selectedKeyword === node.id; // Nested rows are placed by their indentation, so they show their own name. // A root spells out its path, which matters when an intermediate tag is // missing from this client's settings and the row would otherwise read as a // bare leaf name. const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; const label = labelCandidates[0]; // Toasts have the room for the whole thing, and no indentation to lean on, // so they always spell out the full path - otherwise two leaves with the // same name in different branches (e.g. "Personal/Receipts" and // "Work/Receipts") would read as the same tag. const fullLabel = tagNameCandidates(node.id)[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ tagId: node.id, onSuccess: (count) => { if (count === 1) { toast.success(t('email_tagged'), fullLabel); } else { toast.success(t('emails_tagged', { count }), fullLabel); } }, onError: () => { toast.error(t('tag_failed'), fullLabel); }, }); const tagIcon = colorful ? ( ) : ( ); return ( <> onTagSelect?.(isSelected ? null : node.id)} hasChildren={hasChildren} isExpanded={isExpanded} onExpandToggle={() => onToggleExpand(node.id)} isCollapsed={isCollapsed} dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} isValidDropTarget={isValidDropTarget} /> {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( ))} ); } function DemoBanner() { const t = useTranslations('sidebar'); const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo }))); const { startTour, resetTourCompletion } = useTour(); const router = useRouter(); const [isResetting, setIsResetting] = useState(false); if (!isDemoMode) return null; const handleReset = async () => { setIsResetting(true); router.push('/'); // Also put the seeded demo preferences (tags, merged inbox, templates) back. try { localStorage.removeItem('bulwark-demo-seed'); } catch { /* storage blocked */ } await loginDemo(); setIsResetting(false); }; const handleStartTour = () => { resetTourCompletion(); router.push('/'); setTimeout(() => startTour(), 100); }; return (
{t("demo_banner")}
); } function VacationBanner() { const t = useTranslations('sidebar'); const router = useRouter(); const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported }))); if (!isSupported || !isEnabled) return null; return ( ); } function SidebarImpl({ mailboxes = [], selectedMailbox = "", selectedKeyword = null, onMailboxSelect, onTagSelect, onCompose, onSidebarClose, onUnreadFilterClick, onMarkFolderRead, onMarkFolderTreeRead, onMarkAllFoldersRead, onEmptyFolder, onCreateSubfolder, onCreateFolder, onRenameFolder, onDeleteFolder, onShareFolder, onImportEmail, onRefreshMailboxes, scheduledTotal = 0, scheduledTotalByAccount, scheduledAccountScope = null, showScheduledMailbox = false, crossAccountActive = false, showCrossUnread = false, showCrossStarred = false, onOpenStarred, showCrossAll = false, crossUnreadCount = 0, className, multiAccountMode = false, accountMailboxes, viewingAccountId = null, onAccountMailboxSelect, mvShell = false, }: SidebarProps) { const router = useRouter(); const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed }))); // Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on // it slides the full list back out over the message list and takes it away // again on leaving, so a folder two clicks deep costs no clicks at all. It // floats rather than widening the column, or every hover would reflow the // list behind it. const [railHovered, setRailHovered] = useState(false); const hoverExpanded = mvShell && sidebarCollapsed && railHovered; const isCollapsed = sidebarCollapsed && !hoverExpanded; const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId }))); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); const [showAllTags, setShowAllTags] = useState(false); const [moreExpanded, setMoreExpanded] = useState(false); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarFoldersExpanded'); return stored !== null ? JSON.parse(stored) : true; } catch { return true; } }); const [tagsExpanded, setTagsExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarTagsExpanded'); return stored !== null ? JSON.parse(stored) : true; } catch { return true; } }); const [unifiedExpanded, setUnifiedExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarUnifiedExpanded'); return stored !== null ? JSON.parse(stored) : true; } catch { return true; } }); const [sharedExpanded, setSharedExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarSharedExpanded'); return stored !== null ? JSON.parse(stored) : false; } catch { return false; } }); const [expandedSharedAccounts, setExpandedSharedAccounts] = useState>(() => { try { const stored = localStorage.getItem('sidebarExpandedSharedAccounts'); return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set(); } catch { return new Set(); } }); // Per-connected-account collapse state for Pro / Thunderbird-style mode. // Stored as the set of accountIds the user has explicitly collapsed - // anything not in the set is treated as expanded. Inverting the storage // model lets new accounts default to expanded automatically. const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { try { const stored = localStorage.getItem('sidebarCollapsedAccountGroups'); if (stored !== null) return new Set(JSON.parse(stored) as string[]); } catch { /* fall through */ } return new Set(); }); // "Your folders" sections the user has collapsed: OWN_FOLDERS_KEY for the // single-account list, the accountId for each Pro account group. const [collapsedCustomFolders, setCollapsedCustomFolders] = useState>(() => { try { const stored = localStorage.getItem('sidebarCollapsedCustomFolders'); if (stored !== null) return new Set(JSON.parse(stored) as string[]); } catch { /* fall through */ } return new Set(); }); const emailKeywords = useSettingsStore(s => s.emailKeywords); const nestedTags = useSettingsStore(s => s.nestedTags); const tagsAboveShared = usePolicyStore(s => s.isFeatureEnabled('tagsAboveSharedEnabled')); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's // own AccountSwitcher would be a redundant second account UI in the same // pane. const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded; const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified); const showSharedMailAccounts = useSettingsStore(s => s.showSharedMailAccounts); const hiddenSharedMailAccounts = useSettingsStore(s => s.hiddenSharedMailAccounts); // Mountain View draws its navigation icons in the text colour. const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons) && !mvShell; const tagCounts = useEmailStore(s => s.tagCounts); const accounts = useAccountStore(s => s.accounts); const connectedAccounts = accounts.filter(a => a.isConnected); const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]); // Pro shell treats the unified mailbox as a core part of the multi-account // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a // single account we still surface unified when the user has opted into // merging group/shared inboxes — otherwise the counts would just duplicate // the one inbox. // Only show the section when mail-app will actually populate it: cross-account // needs the admin gate + user toggle (`crossAccountActive`, which already // implies 2+ connected accounts), otherwise a merged group inbox or one of // the cross views must exist. A bare "2+ accounts" left an empty header. (#843) const anyCrossViewEnabled = showCrossUnread || showCrossStarred || showCrossAll; const showUnified = (multiAccountMode || enableUnifiedMailbox) && (crossAccountActive || (includeGroupInUnified && hasGroupInboxes) || anyCrossViewEnabled); const { unifiedCounts } = useEmailStore(useShallow((state) => ({ unifiedCounts: state.unifiedCounts }))); // While the other logins are still reconnecting after a load, the unified // counts cover only the ones already back: say how many, so a partial total // is not read as the whole. Gone as soon as the restore finishes, whatever // became of the logins that did not make it. const unifiedScope = useEmailStore((s) => s.unifiedScope); const restoringAccounts = useAuthStore((s) => s.restoringAccounts); const unifiedProgress = useMemo(() => unifiedLoadProgress({ crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts, }), [crossAccountActive, restoringAccounts, accounts, unifiedScope]); const t = useTranslations('sidebar'); useEffect(() => { const stored = localStorage.getItem('expandedMailboxes'); if (stored) { try { const parsed = JSON.parse(stored); setExpandedFolders(new Set(parsed)); } catch (e) { debug.error('Failed to parse expanded mailboxes:', e); } } else { const tree = buildMailboxTree(mailboxes); const collectExpandable = (nodes: MailboxNode[]): string[] => { const ids: string[] = []; for (const node of nodes) { if (node.children.length > 0) { ids.push(node.id); ids.push(...collectExpandable(node.children)); } } return ids; }; setExpandedFolders(new Set(collectExpandable(tree))); } }, [mailboxes]); const handleToggleExpand = (mailboxId: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(mailboxId)) { next.delete(mailboxId); } else { next.add(mailboxId); } try { localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next))); } catch { /* storage full or unavailable */ } return next; }); }; useEffect(() => { const stored = localStorage.getItem('expandedTags'); if (stored) { try { const parsed = JSON.parse(stored); setExpandedTags(new Set(parsed)); } catch (e) { debug.error('Failed to parse expanded tags:', e); } } else { setExpandedTags( new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id)) ); } }, [emailKeywords]); const handleToggleTagExpand = (keywordId: string) => { setExpandedTags((prev) => { const next = new Set(prev); if (next.has(keywordId)) { next.delete(keywordId); } else { next.add(keywordId); } try { localStorage.setItem('expandedTags', JSON.stringify(Array.from(next))); } catch { /* storage full or unavailable */ } return next; }); }; // When the app renders its own virtual "Scheduled" folder (for delayed // sends, driven by EmailSubmission), hide the server-provided scheduled // mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled') // so it does not appear twice. (#495) const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled'; const mailboxTree = buildMailboxTree(mailboxes); const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n)); const hiddenSharedIds = hiddenSharedMailIds(hiddenSharedMailAccounts, activeAccountId); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-') && isSharedMailAccountVisible(n.accountId, showSharedMailAccounts, hiddenSharedIds)); // Mountain View's navigation is one flat list of the few places mail // actually goes, with everything else - the folders you made, the shared // ones, the labels - behind a single "More". The "Folders" and "Tags" // headings say the same thing the icons already do, so the first heading // goes and the rest move below the expander. The collapsed rail is left as // it is: it shows icons only, and hiding half of them behind a word would // leave a control with no room to put it. const mvFlat = mvShell && !isCollapsed; const isPrimaryFolder = (node: MailboxNode) => node.role === 'inbox' || node.role === 'sent' || node.role === 'drafts'; // Everywhere else the folders the user made sit in a "Your folders" section // of their own below the system folders (#1205). Folders nested under a // system folder stay with their parent. With no folders of your own there is // no section, so the list looks as it always did. const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree.filter(isSystemFolder); const customTree = mvFlat ? [] : ownTree.filter((node) => !isSystemFolder(node)); const secondaryTree = mvFlat ? ownTree.filter((node) => !isPrimaryFolder(node)) : []; // Of those, the system folders (archive, spam, trash...) go behind "More", // and the folders the user made are listed with the tags under "Labels", // which is what they are to someone coming from Gmail. const moreTree = secondaryTree.filter((node) => !!node.role); const labelTree = secondaryTree.filter((node) => !node.role); // Everything under "More" is hidden together, so one flag covers the extra // folders, the shared accounts and the labels. const showSecondary = !mvFlat || moreExpanded; // With nesting off every tag is its own root, so the same rows render through // one path whether or not the ids describe a hierarchy. const tagTree: KeywordNode[] = nestedTags ? buildKeywordTree(emailKeywords) : emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 })); // Counts arrive from a separate JMAP round trip, one batch per group of tags; // a tag with no count yet is treated as visible rather than blanking it and // filling it back in. A tag the server answered for with zero unread hides, // which is the point of the setting. const isTagVisible = (node: KeywordNode) => { if (showAllTags || node.id === selectedKeyword) return true; const visibility = getKeywordVisibility(node); if (visibility === 'hide') return false; if (visibility === 'unread') { const count = tagCounts[node.id]; return !count || count.unread > 0; } return true; }; const visibleTagTree = filterKeywordTree(tagTree, isTagVisible); const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree); // Multi-account mode (Pro shell): render every connected account as its // own collapsible group. The active account's tree comes from the // `mailboxes` prop (which is the live email-store value); other accounts // come from the per-account cache populated by useProMultiAccountMailboxes. const useMultiAccount = multiAccountMode && connectedAccounts.length > 1; const accountGroups = useMultiAccount ? connectedAccounts.map((account) => { const isActive = account.id === activeAccountId; const accountMailboxList = isActive ? mailboxes : (accountMailboxes?.[account.id] ?? []); const tree = buildMailboxTree(accountMailboxList).filter( (n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n)) ); return { account, isActive, tree }; }) : []; // Scheduled row. Without an account it is the combined view (the user's own // tree); with one it scopes the list to that shared account's scheduled mail, // which lives in the shared JMAP account rather than the primary one (#874). const renderScheduledRow = (key: string, account?: { accountId?: string; depth?: number }) => { if (!showScheduledMailbox) return null; const accountId = account?.accountId; const mailboxId = accountId ? scopedScheduledMailboxId(accountId) : SCHEDULED_MAILBOX_ID; const count = accountId ? (scheduledTotalByAccount?.[accountId] ?? 0) : scheduledTotal; // selectedMailbox is always the plain virtual id, so the *scope* decides // which of these rows is the active one. const isScheduledSelected = selectedMailbox === SCHEDULED_MAILBOX_ID && (scheduledAccountScope ?? null) === (accountId ?? null); return ( } label={t('scheduled')} depth={account?.depth ?? 0} isSelected={!selectedKeyword && isScheduledSelected} total={count} onClick={() => onMailboxSelect?.(mailboxId)} isCollapsed={isCollapsed} testRole="scheduled" testName="scheduled" testMailboxId={mailboxId} /> ); }; // The "Your folders" section: the user's own top-level folders, under a // heading of their own that collapses and offers to make a new one. const renderCustomFolders = ( key: string, tree: MailboxNode[], { sub = false, selected, onSelect, active = true, dragAccountId, }: { sub?: boolean; selected: string; onSelect?: (mailboxId: string) => void; active?: boolean; dragAccountId?: string | null; }, ) => { if (tree.length === 0) return null; const expanded = !collapsedCustomFolders.has(key); return ( // A Pro account group already carries the folders menu for the whole group.
toggleCustomFolders(key)} onAdd={active && onCreateFolder ? () => onCreateFolder() : undefined} addTitle={t("create_folder")} isCollapsed={isCollapsed} sub={sub} testId={`section-your-folders-header-${key}`} /> {((expanded && !isCollapsed) || isCollapsed) && tree.map((node) => ( ))}
); }; // The Tags section. It follows the shared mailboxes unless the admin policy // lists it above them, so a long list of shared mailboxes does not push the // tags out of sight. const renderTags = () => { if (!(showSecondary || mvFlat) || emailKeywords.length === 0) return null; return (
{/* Under Mountain View the tags continue the "Labels" list. */} {!mvFlat && ( )} {((tagsExpanded && !isCollapsed) || isCollapsed || mvFlat) && ( <> {visibleTagTree.map((node) => ( ))} {(hiddenTagCount > 0 || showAllTags) && ( setShowAllTags((prev) => !prev)} isCollapsed={isCollapsed} /> )} )}
); }; const getUnifiedIcon = (role: UnifiedMailboxRole) => { switch (role) { case 'inbox': return Inbox; case 'sent': return Send; case 'drafts': return File; case 'trash': return Trash2; case 'archive': return Archive; case 'junk': return Ban; default: return Folder; } }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't hijack Arrow keys while the user is typing. This is a global // window listener, so without this guard typing in a new email (the // contentEditable composer, the subject field, search, etc.) toggled the // selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight. // composedPath-based so it also sees the QuotedHtml shadow island (#654). if (isEditableEventTarget(e)) return; if (!selectedMailbox || isCollapsed) return; const findNode = (nodes: MailboxNode[]): MailboxNode | null => { for (const node of nodes) { if (node.id === selectedMailbox) return node; const found = findNode(node.children); if (found) return found; } return null; }; const selectedNode = findNode(mailboxTree); if (!selectedNode) return; if (e.key === 'ArrowRight' && selectedNode.children.length > 0) { if (!expandedFolders.has(selectedMailbox)) { handleToggleExpand(selectedMailbox); } } else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) { if (expandedFolders.has(selectedMailbox)) { handleToggleExpand(selectedMailbox); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]); const toggleUnified = () => { setUnifiedExpanded((prev: boolean) => { const next = !prev; try { localStorage.setItem('sidebarUnifiedExpanded', JSON.stringify(next)); } catch { /* */ } return next; }); }; const toggleFolders = () => { setFoldersExpanded((prev: boolean) => { const next = !prev; try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ } return next; }); }; const toggleTags = () => { setTagsExpanded((prev: boolean) => { const next = !prev; try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ } return next; }); }; const toggleShared = () => { setSharedExpanded((prev: boolean) => { const next = !prev; try { localStorage.setItem('sidebarSharedExpanded', JSON.stringify(next)); } catch { /* */ } return next; }); }; const toggleSharedAccount = (id: string) => { setExpandedSharedAccounts((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); try { localStorage.setItem('sidebarExpandedSharedAccounts', JSON.stringify(Array.from(next))); } catch { /* */ } return next; }); }; const toggleAccountGroup = (id: string) => { setCollapsedAccountGroups((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); try { localStorage.setItem('sidebarCollapsedAccountGroups', JSON.stringify(Array.from(next))); } catch { /* */ } return next; }); }; const toggleCustomFolders = (key: string) => { setCollapsedCustomFolders((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); try { localStorage.setItem('sidebarCollapsedCustomFolders', JSON.stringify(Array.from(next))); } catch { /* */ } return next; }); }; // The tab now travels in the URL (#733) rather than through sessionStorage, // so a gear click is a link the user can also bookmark or share. It still // steers only this one open - the persisted default is written on tab click. const openFolderSettings = () => { router.push(buildSettingsPath('organize', 'folders')); }; const openKeywordSettings = () => { router.push(buildSettingsPath('organize', 'tags')); }; const { contextMenu: mailboxContextMenu, openContextMenu: openMailboxContextMenu, closeContextMenu: closeMailboxContextMenu, menuRef: mailboxMenuRef, } = useContextMenu(); const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => { const mailbox = mailboxes.find(mb => mb.id === node.id); if (!mailbox) return; openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 }); }; const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => { openMailboxContextMenu(e, { kind: "folders-section" }); }; // `buildMailboxTree()` groups shared mailboxes that carry no accountId under // the placeholder id "unknown" (lib/utils.ts). Such a node cannot be a valid // JMAP mutation target, so it gets no folder-creation menu at all rather than // a menu that would create the folder in a non-existent account. const sharedAccountMenuId = (accountId?: string) => accountId && accountId !== "unknown" ? accountId : undefined; const handleSharedAccountContextMenu = (e: React.MouseEvent, accountId: string) => { openMailboxContextMenu(e, { kind: "folders-section", accountId }); }; const hideSharedAccount = activeAccountId ? (accountId: string) => { const { hiddenSharedMailAccounts: hidden, updateSetting } = useSettingsStore.getState(); updateSetting('hiddenSharedMailAccounts', setSharedMailAccountHidden(hidden, activeAccountId, accountId, true)); toast.info(t("shared_account_hidden")); } : undefined; // Muting only takes effect through the server's emailPush filter, so the // menu offers it only where that exists, like the "Inbox only" setting. const authClient = useAuthStore((s) => s.client); const notificationMutes = useSettingsStore((s) => s.notificationMutes); const notificationActions = useMemo(() => { if (!authClient || !serverSupportsEmailPush(authClient)) return undefined; const accountOf = (accountId?: string) => accountId || authClient.getAccountId(); const mailboxRef = (mailbox: Mailbox) => [accountOf(mailbox.accountId), mailbox.originalId || mailbox.id] as const; const save = (next: NotificationMutes) => useSettingsStore.getState().updateSetting('notificationMutes', next); return { isMailboxMuted: (mailbox) => isMailboxMuted(notificationMutes, ...mailboxRef(mailbox)), isAccountMuted: (accountId) => isAccountMuted(notificationMutes, accountOf(accountId)), hasFolderSettings: (accountId) => { const { muted, unmuted } = mailboxOverridesForAccount(notificationMutes, accountOf(accountId)); return muted.length + unmuted.length > 0; }, setMailboxMuted: (mailbox, muted) => save(setMailboxMuted(notificationMutes, ...mailboxRef(mailbox), muted)), setAccountMuted: (accountId, muted) => save(setAccountMuted(notificationMutes, accountOf(accountId), muted)), resetFolderSettings: (accountId) => save(resetMailboxMutes(notificationMutes, accountOf(accountId))), }; }, [authClient, notificationMutes]); return (
setRailHovered(true) : undefined} onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined} data-mv-rail-peek={hoverExpanded ? "" : undefined} className={cn( "relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden", // Mountain View's navigation sits on the canvas, with no rule between // it and the content card. mvShell ? "bg-[var(--mv-canvas)] border-transparent" : "bg-secondary border-border", "max-lg:w-full", // Mountain View's collapsed column is 72px (--mv-rail-w), wide enough // for its round compose button; the default layout's rail is 48px. isCollapsed && !mvShell ? "lg:w-12" : "lg:w-full", // Floats out of the rail's own column rather than widening it, so the // message list underneath never moves while the pointer passes over. // Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek. hoverExpanded && "lg:absolute lg:inset-y-0 lg:start-0 lg:z-40 lg:shadow-xl", className )} > {/* Header - hidden in the Pro shell, which owns its own chrome and would otherwise render an empty strip (no collapse, no switcher). */} {!isEmbedded && !mvShell && ( // Border lives on the wrapper (outside the h-14 box) so the bar's total // height matches the search/reply toolbars, which border-b their wrapper too.
{!isCollapsed && !hideAccountSwitcher && ( )}
)} {mvShell && onCompose && (
)} {!isCollapsed && } {!isCollapsed && } {/* Mailbox List */}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
{t("all_accounts_loading", unifiedProgress)} )} /> {((unifiedExpanded && !isCollapsed) || isCollapsed) && ( <> {showUnified && unifiedCounts.map((count) => { const unifiedId = UNIFIED_MAILBOX_IDS[count.role]; const Icon = getUnifiedIcon(count.role); const isSelected = !selectedKeyword && selectedMailbox === unifiedId; return ( } label={t(`unified_${count.role}`)} testRole={count.role} testName={`unified-${count.role}`} testMailboxId={unifiedId} depth={0} isSelected={isSelected} unread={count.unreadEmails} total={count.totalEmails} onClick={() => onMailboxSelect?.(unifiedId)} isCollapsed={isCollapsed} /> ); })} {[ { show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount }, { show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined }, { show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount }, ].map(({ show, id, Icon, label, unread }) => { if (!show) return null; const isSelected = !selectedKeyword && selectedMailbox === id; return ( } label={label} testName={id} testMailboxId={id} depth={0} isSelected={isSelected} unread={unread} onClick={() => onMailboxSelect?.(id)} isCollapsed={isCollapsed} /> ); })} )}
)} {useMultiAccount ? ( accountGroups.map(({ account, isActive, tree }) => { const expanded = !collapsedAccountGroups.has(account.id); const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id; return (
toggleAccountGroup(account.id)} onSettings={isActive ? openFolderSettings : undefined} settingsTitle={isActive ? t('settings') : undefined} isCollapsed={isCollapsed} first={!showUnified && account.id === connectedAccounts[0]?.id} icon={} /> {((expanded && !isCollapsed) || isCollapsed) && ( <> {tree.length === 0 ? (
{!isCollapsed && t("loading_mailboxes")}
) : ( <> {tree.filter(isSystemFolder).map((node) => ( onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) } onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={isActive ? handleMailboxContextMenu : undefined} dragAccountId={isActive ? null : account.id} /> {isActive && node.role === 'drafts' && renderScheduledRow(`scheduled-${account.id}`)} ))} {isActive && !tree.some((node) => node.role === 'drafts') && renderScheduledRow(`scheduled-${account.id}`)} {renderCustomFolders(account.id, tree.filter((node) => !isSystemFolder(node)), { sub: true, selected: selectedKeyword || !isViewing ? "" : selectedMailbox, onSelect: (mailboxId) => onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId), active: isActive, dragAccountId: isActive ? null : account.id, })} )} )}
); }) ) : (
{!mvFlat && ( )} {(mvFlat || (foldersExpanded && !isCollapsed) || isCollapsed) && ( <> {mailboxes.length === 0 ? (
{!isCollapsed && t("loading_mailboxes")}
) : ( <> {primaryTree.map((node) => ( {node.role === 'drafts' && renderScheduledRow('scheduled')} {/* Mountain View lists Starred right under the Inbox. */} {mvFlat && node.role === 'inbox' && onOpenStarred && ( } label={t("mailboxes.starred")} testName="mv-starred" depth={0} isSelected={!selectedKeyword && selectedMailbox === CROSS_VIEW_IDS.starred} onClick={onOpenStarred} isCollapsed={false} /> )} ))} {!primaryTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')} {mvFlat && ( : } label={moreExpanded ? t("show_fewer") : t("show_more")} testName="mv-more" depth={0} isSelected={false} onClick={() => setMoreExpanded((prev) => !prev)} isCollapsed={false} /> )} {mvFlat && moreExpanded && moreTree.map((node) => ( ))} )} )}
)} {!useMultiAccount && renderCustomFolders(OWN_FOLDERS_KEY, customTree, { selected: selectedKeyword ? "" : selectedMailbox, onSelect: onMailboxSelect, })} {mvFlat && !useMultiAccount && (
{t("labels")} {onCreateFolder && ( )}
{labelTree.map((node) => ( ))}
)} {tagsAboveShared && renderTags()} {!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
{((sharedExpanded && !isCollapsed) || isCollapsed) && ( <> {sharedAccounts.map((account) => { const accountExpanded = expandedSharedAccounts.has(account.id); const menuAccountId = sharedAccountMenuId(account.accountId); return (
toggleSharedAccount(account.id)} isCollapsed={isCollapsed} sub icon={} testId="section-shared-account" onContextMenu={menuAccountId ? (e) => handleSharedAccountContextMenu(e, menuAccountId) : undefined} /> {accountExpanded && !isCollapsed && ( <> {account.children.map((child) => ( {child.role === 'drafts' && menuAccountId && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} ))} {menuAccountId && !account.children.some((child) => child.role === 'drafts') && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} )}
); })} )}
)} {!tagsAboveShared && renderTags()} {!isCollapsed && } {!isCollapsed && }
); } /** * The mail sidebar, re-rendered only when one of its props changes. The mail * view renders on every open and every list update; without this each of * those rendered the whole folder tree again. Its handlers come from * useStableHandlers in the mail view, so they keep their identity. */ export const Sidebar = memo(SidebarImpl);