"use client"; import { useState, useEffect, useMemo, ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Button } from "@/components/ui/button"; import { Inbox, Send, File, Star, Trash2, Archive, Ban, ChevronsLeft, ChevronsRight, ChevronRight, ChevronDown, Folder, FolderOpen, User, Users, Palmtree, Settings, X, RotateCcw, Tag, FlaskConical, PlayCircle, Loader2, AlertTriangle, NotebookPen, CalendarClock, BellOff, Mails, MailOpen, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; import { useAccountStore } from '@/stores/account-store'; 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 { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-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 { useTour } from "@/components/tour/tour-provider"; interface SidebarProps { mailboxes: Mailbox[]; selectedMailbox?: string; selectedKeyword?: string | null; onMailboxSelect?: (mailboxId: string) => void; onTagSelect?: (keywordId: string | null) => void; onCompose?: () => void; 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?: () => void; onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; scheduledTotal?: number; 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; 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; const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, _isShared?: boolean, id?: string) => { const lowerName = name?.toLowerCase() || ""; if (id?.startsWith('shared-account-')) { return User; } if (role === "inbox" || lowerName.includes("inbox")) return Inbox; if (role === "sent" || lowerName.includes("sent")) return Send; if (role === "drafts" || lowerName.includes("draft")) return File; if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban; if (role === "archive" || lowerName.includes("archive")) return Archive; if (role === "shared" || lowerName.includes("shared")) return Users; if (role === "important" || lowerName.includes("important")) return AlertTriangle; if (role === "memos" || lowerName.includes("memo")) return NotebookPen; if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock; if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff; if (lowerName.includes("star") || lowerName.includes("flag")) 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 resolveRoleKey(role?: string, name?: string): string | undefined { const lowerName = name?.toLowerCase() || ""; if (role === "inbox" || lowerName.includes("inbox")) return "inbox"; if (role === "sent" || lowerName.includes("sent")) return "sent"; if (role === "drafts" || lowerName.includes("draft")) return "drafts"; if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash"; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk"; if (role === "archive" || lowerName.includes("archive")) return "archive"; if (role === "shared" || lowerName.includes("shared")) return "shared"; if (role === "important" || lowerName.includes("important")) return "important"; if (role === "memos" || lowerName.includes("memo")) return "memos"; if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled"; if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed"; return undefined; } 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, onUnreadClick, }: { unread?: number; total?: number; isSelected: boolean; onUnreadClick?: () => void; }) { const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount); const unreadCount = unread ?? 0; const totalCount = showFolderTotalCount ? (total ?? 0) : 0; 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; 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; /** Stable identifiers for integration tests (not user-visible). */ testRole?: string | null; testName?: string; testMailboxId?: string; testShared?: boolean; } function SidebarRow({ icon, label, depth = 0, isSelected = false, isVirtual = false, unread, total, onClick, hasChildren = false, isExpanded = false, onExpandToggle, onUnreadClick, isCollapsed, dropHandlers, isValidDropTarget, isInvalidDropTarget, onContextMenu, testRole, testName, testMailboxId, testShared, }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; return (
{!isCollapsed && (
{hasChildren && onExpandToggle ? ( ) : (
)}
)}
); } function SidebarSectionHeader({ label, expanded, onToggle, onSettings, settingsTitle, isCollapsed, first, icon, sub, testId, }: { label: string; expanded: boolean; onToggle: () => void; onSettings?: () => void; settingsTitle?: string; isCollapsed: boolean; first?: boolean; icon?: ReactNode; sub?: boolean; testId?: string; }) { 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 ( ); } function MailboxTreeItem({ node, selectedMailbox, expandedFolders, onMailboxSelect, onToggleExpand, isCollapsed, onUnreadFilterClick, colorful, onContextMenu, }: { 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; }) { 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, node.name, hasChildren, isExpanded, node.isShared, node.id); const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const roleKey = resolveRoleKey(node.role, node.name); const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ mailbox: node, 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={globalDragging ? (dropHandlers as Record) : undefined} isValidDropTarget={isValidDropTarget} isInvalidDropTarget={isInvalidDropTarget} onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined} /> {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( ))} ); } const TAG_ICON_COLOR: Record = { red: "text-red-600/75 dark:text-red-400/75", orange: "text-orange-600/75 dark:text-orange-400/75", yellow: "text-yellow-600/75 dark:text-yellow-400/75", green: "text-green-600/75 dark:text-green-400/75", blue: "text-blue-600/75 dark:text-blue-400/75", purple: "text-purple-600/75 dark:text-purple-400/75", pink: "text-pink-600/75 dark:text-pink-400/75", teal: "text-teal-600/75 dark:text-teal-400/75", cyan: "text-cyan-600/75 dark:text-cyan-400/75", indigo: "text-indigo-600/75 dark:text-indigo-400/75", amber: "text-amber-600/75 dark:text-amber-400/75", lime: "text-lime-600/75 dark:text-lime-400/75", gray: "text-gray-500", }; function TagItem({ kw, isSelected, isCollapsed, onTagSelect, totalCount, unreadCount, colorful, }: { kw: KeywordDefinition; isSelected: boolean; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; totalCount: number; unreadCount: number; colorful: boolean; }) { const t = useTranslations('notifications'); const palette = KEYWORD_PALETTE[kw.color]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ tagId: kw.id, onSuccess: (count, _tagLabel) => { if (count === 1) { toast.success(t('email_tagged'), kw.label); } else { toast.success(t('emails_tagged', { count }), kw.label); } }, onError: () => { toast.error(t('tag_failed'), kw.label); }, }); const tagIcon = colorful ? ( ) : ( ); return ( onTagSelect?.(isSelected ? null : kw.id)} isCollapsed={isCollapsed} dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} isValidDropTarget={isValidDropTarget} /> ); } function DemoBanner() { const t = useTranslations('sidebar'); const { isDemoMode, loginDemo } = useAuthStore(); const { startTour, resetTourCompletion } = useTour(); const router = useRouter(); const [isResetting, setIsResetting] = useState(false); if (!isDemoMode) return null; const handleReset = async () => { setIsResetting(true); router.push('/'); 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(); if (!isSupported || !isEnabled) return null; return ( ); } export function Sidebar({ mailboxes = [], selectedMailbox = "", selectedKeyword = null, onMailboxSelect, onTagSelect, onCompose: _onCompose, onSidebarClose, onUnreadFilterClick, onMarkFolderRead, onMarkFolderTreeRead, onMarkAllFoldersRead, onEmptyFolder, onCreateSubfolder, onCreateFolder, onRenameFolder, onDeleteFolder, onImportEmail, onRefreshMailboxes, scheduledTotal = 0, showScheduledMailbox = false, crossAccountActive = false, showCrossUnread = false, showCrossStarred = false, showCrossAll = false, crossUnreadCount = 0, className, multiAccountMode = false, accountMailboxes, viewingAccountId = null, onAccountMailboxSelect, }: SidebarProps) { const router = useRouter(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); 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(); }); const emailKeywords = useSettingsStore(s => s.emailKeywords); 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 colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); 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. const showUnified = (multiAccountMode || enableUnifiedMailbox) && (connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes)); const { unifiedCounts } = useEmailStore(); 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; }); }; // 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 sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); // 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 }; }) : []; 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 openFolderSettings = () => { try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ } router.push('/settings'); }; const openKeywordSettings = () => { try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ } router.push('/settings'); }; 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" }); }; return (
{/* Header - hidden in the Pro shell, which owns its own chrome and would otherwise render an empty strip (no collapse, no switcher). */} {!isEmbedded && ( // 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 && ( )}
)} {!isCollapsed && } {!isCollapsed && } {/* Mailbox List */}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
{((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.map((node) => ( onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) } onToggleExpand={handleToggleExpand} isCollapsed={isCollapsed} onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} colorful={colorfulSidebarIcons} onContextMenu={isActive ? handleMailboxContextMenu : undefined} /> ))} {isActive && showScheduledMailbox && ( } label={t('scheduled')} depth={0} isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} total={scheduledTotal} onClick={() => onMailboxSelect?.('__scheduled__')} isCollapsed={isCollapsed} /> )} )} )}
); }) ) : (
{((foldersExpanded && !isCollapsed) || isCollapsed) && ( <> {mailboxes.length === 0 ? (
{!isCollapsed && t("loading_mailboxes")}
) : ( <> {ownTree.map((node) => ( ))} {showScheduledMailbox && ( } label={t('scheduled')} depth={0} isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} total={scheduledTotal} onClick={() => onMailboxSelect?.('__scheduled__')} isCollapsed={isCollapsed} /> )} )} )}
)} {!useMultiAccount && sharedAccounts.length > 0 && (
{((sharedExpanded && !isCollapsed) || isCollapsed) && ( <> {sharedAccounts.map((account) => { const accountExpanded = expandedSharedAccounts.has(account.id); return (
toggleSharedAccount(account.id)} isCollapsed={isCollapsed} sub icon={} testId="section-shared-account" /> {accountExpanded && !isCollapsed && account.children.map((child) => ( ))}
); })} )}
)} {emailKeywords.length > 0 && (
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( <> {emailKeywords.map((kw) => ( ))} )}
)} {!isCollapsed && }
); }