"use client"; import { hoverOpens } from "@/lib/open-on-hover"; import { memo, useState, useEffect, useMemo, useCallback, useRef, useId, 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, TOUCH_HIT_AREA } 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, MoreVertical, } 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 { TagScopeContext, useAccountTags } from "@/hooks/use-account-tags"; import { isAppShortcutBlocked, isEventInside } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useLongPress } from "@/hooks/use-long-press"; import { useMediaQuery } from "@/hooks/use-media-query"; import { IconButton } from "@/components/ui/icon-button"; 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, buildTagViewAccountClients } from "@/stores/email-store"; import { trashAndJunkIds } from "@/lib/unified-mailbox"; import { toast } from "@/stores/toast-store"; import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; 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, accountId?: 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; }; // Role tints from the theme's tokens (R11), so a theme recolours them and // dark mode has its own values: the chart palette for the main roles. // With colour-blind-safe colours on () each // colour is swapped for one of the palette in lib/cvd-palette.ts. Written // out in full so Tailwind generates the classes. const ROLE_ICON_COLOR: Record = { inbox: "text-chart-1/80 [[data-cvd-palette=on]_&]:text-[#0072B2]", sent: "text-chart-2/80 [[data-cvd-palette=on]_&]:text-[#009E73]", drafts: "text-chart-5/80 [[data-cvd-palette=on]_&]:text-[#CC79A7]", trash: "text-muted-foreground", junk: "text-chart-4/80 [[data-cvd-palette=on]_&]:text-[#D55E00]", archive: "text-chart-3/80 [[data-cvd-palette=on]_&]:text-[#56B4E9]", shared: "text-info/80 [[data-cvd-palette=on]_&]:text-[#F0E442]", important: "text-warning/80 [[data-cvd-palette=on]_&]:text-[#E69F00]", memos: "text-warning/80 [[data-cvd-palette=on]_&]:text-[#E69F00]", scheduled: "text-info/80 [[data-cvd-palette=on]_&]:text-[#F0E442]", snoozed: "text-muted-foreground", }; 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 t = useTranslations('sidebar'); const tShell = useTranslations('shell'); 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)); // Settings > Accessibility: unread counts as numbers, as a dot, or hidden. const unreadCountStyle = useSettingsStore(s => s.unreadCountStyle); const resolved = resolveFolderCounts({ unread, total, role, mode: countMode, showTotalGlobally: showFolderTotalCount, mountainView, }); const counts = unreadCountStyle === 'hidden' ? { ...resolved, unread: 0 } : resolved; const unreadDot = (