mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
# Conflicts: # components/contacts/contacts-app.tsx # components/email/__tests__/composer-discard.test.tsx # components/email/calendar-invitation-banner.tsx # components/email/email-composer.tsx # components/mail/mail-app.tsx # components/pro/pro-compose-tab-body.tsx # components/pro/pro-email-tab-body.tsx # components/ui/__tests__/avatar.test.tsx # components/ui/avatar.tsx # hooks/use-mailbox-drop.ts # lib/__tests__/calendar-alerts.test.ts # lib/__tests__/jmap-calendar-tasks.test.ts # lib/__tests__/jmap-send-threading.test.ts # lib/demo/demo-client.ts # lib/demo/fixtures/calendars.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/web-push.ts # stores/__tests__/contact-store.test.ts # stores/__tests__/email-store-thread-key.test.ts # stores/calendar-store.ts # stores/contact-store.ts # stores/email-store.ts
1933 lines
75 KiB
TypeScript
1933 lines
75 KiB
TypeScript
"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<string, number>;
|
|
/** 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<string, Mailbox[]>;
|
|
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<string, string> = {
|
|
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 (
|
|
<span
|
|
className={cn("ms-2 flex-shrink-0 text-xs tabular-nums", showsTotal ? "text-muted-foreground" : "font-bold text-foreground")}
|
|
data-testid="folder-counts"
|
|
data-unread={unread ?? 0}
|
|
data-total={counts.total}
|
|
>
|
|
{count.toLocaleString()}
|
|
</span>
|
|
);
|
|
}
|
|
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 ? (
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => {
|
|
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}
|
|
</span>
|
|
) : (
|
|
<span className={unreadClass}>{unreadCount}</span>
|
|
)
|
|
) : null;
|
|
|
|
return (
|
|
<span
|
|
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
|
|
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
|
|
data-testid="folder-counts"
|
|
data-unread={unreadCount}
|
|
data-total={totalCount}
|
|
>
|
|
{unreadNode}
|
|
{unreadCount > 0 && totalCount > 0 && (
|
|
<span className="text-xs text-muted-foreground/60">/</span>
|
|
)}
|
|
{totalCount > 0 && <span className={totalClass}>{totalCount}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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 (
|
|
<div
|
|
{...(dropHandlers || {})}
|
|
onContextMenu={onContextMenu}
|
|
data-testid="folder-row"
|
|
data-mv-nav-row=""
|
|
data-folder-role={testRole ?? undefined}
|
|
data-folder-name={testName ?? undefined}
|
|
data-mailbox-id={testMailboxId ?? undefined}
|
|
data-shared={testShared ? 'true' : undefined}
|
|
data-selected={isSelected ? 'true' : undefined}
|
|
data-has-unread={(unread ?? 0) > 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 && (
|
|
<div
|
|
className="flex items-center flex-shrink-0"
|
|
style={{ paddingLeft: leftPad }}
|
|
>
|
|
{hasChildren && onExpandToggle ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onExpandToggle();
|
|
}}
|
|
data-testid="folder-expand-toggle"
|
|
className="flex items-center justify-center rounded hover:bg-muted active:bg-accent transition-colors"
|
|
style={{ width: CHEVRON_SLOT, height: CHEVRON_SLOT }}
|
|
title={isExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div style={{ width: CHEVRON_SLOT }} aria-hidden />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => !isVirtual && onClick?.()}
|
|
disabled={isVirtual}
|
|
className={cn(
|
|
"flex items-center gap-2 min-w-0 transition-colors",
|
|
isCollapsed ? "justify-center" : "flex-1 text-start",
|
|
isVirtual && "cursor-default select-none"
|
|
)}
|
|
title={isCollapsed ? label : undefined}
|
|
>
|
|
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4">
|
|
{icon}
|
|
</span>
|
|
{!isCollapsed && (
|
|
<>
|
|
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
|
{muted && (
|
|
<BellOff
|
|
className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground"
|
|
aria-label={t('notifications_muted')}
|
|
data-testid="folder-muted"
|
|
/>
|
|
)}
|
|
<SidebarRowCounts
|
|
unread={unread}
|
|
total={total}
|
|
role={testRole}
|
|
mailboxId={testMailboxId}
|
|
isSelected={isSelected}
|
|
onUnreadClick={onUnreadClick}
|
|
/>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
onClick={onToggle}
|
|
onContextMenu={onContextMenu}
|
|
data-testid={testId}
|
|
data-section-name={label}
|
|
data-expanded={expanded ? 'true' : 'false'}
|
|
className={cn(
|
|
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
|
paddingX,
|
|
paddingY
|
|
)}
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
|
)}
|
|
{icon && <span className="ms-1.5 flex-shrink-0">{icon}</span>}
|
|
<span className={cn(textClass, icon ? "ms-1.5" : "ms-1.5")}>
|
|
{label}
|
|
</span>
|
|
{status}
|
|
{onAdd && (
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAdd();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onAdd();
|
|
}
|
|
}}
|
|
className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
|
title={addTitle}
|
|
aria-label={addTitle}
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
</span>
|
|
)}
|
|
{onSettings && (
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSettings();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onSettings();
|
|
}
|
|
}}
|
|
className={cn(!onAdd && "ms-auto", "p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer")}
|
|
title={settingsTitle}
|
|
>
|
|
<Settings className="w-3.5 h-3.5" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** 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<string>;
|
|
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<string, unknown> | 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 (
|
|
<>
|
|
<SidebarRow
|
|
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, node.role)} />}
|
|
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<string, unknown>) : {}),
|
|
}
|
|
: undefined
|
|
}
|
|
isValidDropTarget={isValidDropTarget}
|
|
isInvalidDropTarget={isInvalidDropTarget}
|
|
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
|
muted={muted}
|
|
/>
|
|
|
|
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
|
<MailboxTreeItem
|
|
key={child.id}
|
|
node={child}
|
|
selectedMailbox={selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onMailboxSelect}
|
|
onToggleExpand={onToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={onUnreadFilterClick}
|
|
colorful={colorful}
|
|
onContextMenu={onContextMenu}
|
|
dragAccountId={dragAccountId}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ShowAllTagsRow({
|
|
hiddenCount,
|
|
showAll,
|
|
onToggle,
|
|
isCollapsed,
|
|
}: {
|
|
hiddenCount: number;
|
|
showAll: boolean;
|
|
onToggle: () => void;
|
|
isCollapsed: boolean;
|
|
}) {
|
|
const t = useTranslations('sidebar');
|
|
|
|
return (
|
|
<SidebarRow
|
|
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
|
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<string>;
|
|
isCollapsed: boolean;
|
|
onTagSelect?: (keywordId: string | null) => void;
|
|
onToggleExpand: (keywordId: string) => void;
|
|
tagCounts: Record<string, { total: number; unread: number }>;
|
|
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 ? (
|
|
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
|
) : (
|
|
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<SidebarRow
|
|
icon={tagIcon}
|
|
label={label}
|
|
labelCandidates={labelCandidates}
|
|
depth={node.depth}
|
|
isSelected={isSelected}
|
|
unread={tagCounts[node.id]?.unread ?? 0}
|
|
total={tagCounts[node.id]?.total ?? 0}
|
|
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
|
hasChildren={hasChildren}
|
|
isExpanded={isExpanded}
|
|
onExpandToggle={() => onToggleExpand(node.id)}
|
|
isCollapsed={isCollapsed}
|
|
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
|
isValidDropTarget={isValidDropTarget}
|
|
/>
|
|
|
|
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
|
<TagItem
|
|
key={child.id}
|
|
node={child}
|
|
selectedKeyword={selectedKeyword}
|
|
expandedTags={expandedTags}
|
|
isCollapsed={isCollapsed}
|
|
onTagSelect={onTagSelect}
|
|
onToggleExpand={onToggleExpand}
|
|
tagCounts={tagCounts}
|
|
colorful={colorful}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
data-tour="demo-banner"
|
|
className={cn(
|
|
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
|
|
"bg-primary/10 dark:bg-primary/10 text-primary",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="truncate font-medium">{t("demo_banner")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={handleStartTour}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
|
|
title={t("demo_tour")}
|
|
>
|
|
<PlayCircle className="w-3 h-3" />
|
|
{t("demo_tour")}
|
|
</button>
|
|
<button
|
|
onClick={handleReset}
|
|
disabled={isResetting}
|
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
|
|
title={t("demo_reset")}
|
|
>
|
|
{isResetting ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3 h-3" />
|
|
)}
|
|
{t("demo_reset")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
onClick={() => router.push('/settings')}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-3 py-2 text-xs",
|
|
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
|
|
"hover:bg-amber-500/15 dark:hover:bg-amber-400/15 transition-colors"
|
|
)}
|
|
>
|
|
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="truncate font-medium">{t("vacation_active")}</span>
|
|
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<Set<string>>(new Set());
|
|
const [expandedTags, setExpandedTags] = useState<Set<string>>(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<Set<string>>(() => {
|
|
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<Set<string>>(() => {
|
|
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<Set<string>>(() => {
|
|
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 (
|
|
<SidebarRow
|
|
key={key}
|
|
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", mvShell ? "text-foreground/80" : "text-sky-600 dark:text-sky-400")} />}
|
|
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.
|
|
<div data-testid="section-your-folders" onContextMenu={active && !sub ? handleFoldersHeaderContextMenu : undefined}>
|
|
<SidebarSectionHeader
|
|
label={t("your_folders")}
|
|
expanded={expanded}
|
|
onToggle={() => 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) => (
|
|
<MailboxTreeItem
|
|
key={node.id}
|
|
node={node}
|
|
selectedMailbox={selected}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onSelect}
|
|
onToggleExpand={handleToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
|
|
colorful={colorfulSidebarIcons}
|
|
onContextMenu={active ? handleMailboxContextMenu : undefined}
|
|
dragAccountId={dragAccountId}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 (
|
|
<div data-tour="keyword-tags">
|
|
{/* Under Mountain View the tags continue the "Labels" list. */}
|
|
{!mvFlat && (
|
|
<SidebarSectionHeader
|
|
label={t("tags")}
|
|
expanded={tagsExpanded}
|
|
onToggle={toggleTags}
|
|
onSettings={openKeywordSettings}
|
|
settingsTitle={t('settings')}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
)}
|
|
{((tagsExpanded && !isCollapsed) || isCollapsed || mvFlat) && (
|
|
<>
|
|
{visibleTagTree.map((node) => (
|
|
<TagItem
|
|
key={node.id}
|
|
node={node}
|
|
selectedKeyword={selectedKeyword}
|
|
expandedTags={expandedTags}
|
|
isCollapsed={isCollapsed}
|
|
onTagSelect={onTagSelect}
|
|
onToggleExpand={handleToggleTagExpand}
|
|
tagCounts={tagCounts}
|
|
colorful={colorfulSidebarIcons}
|
|
/>
|
|
))}
|
|
{(hiddenTagCount > 0 || showAllTags) && (
|
|
<ShowAllTagsRow
|
|
hiddenCount={hiddenTagCount}
|
|
showAll={showAllTags}
|
|
onToggle={() => setShowAllTags((prev) => !prev)}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<MailboxContextTarget>();
|
|
|
|
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<MailboxNotificationActions | undefined>(() => {
|
|
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 (
|
|
<div
|
|
onMouseEnter={mvShell && sidebarCollapsed ? () => 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.
|
|
<div className="border-b border-border">
|
|
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onSidebarClose}
|
|
className="lg:hidden h-9 w-9 flex-shrink-0"
|
|
aria-label={t("close")}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggleSidebarCollapsed}
|
|
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
|
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
|
>
|
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
|
</Button>
|
|
|
|
{!isCollapsed && !hideAccountSwitcher && (
|
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mvShell && onCompose && (
|
|
<div className={cn("pt-2 pb-3", isCollapsed ? "px-2" : "px-3")}>
|
|
<button
|
|
type="button"
|
|
onClick={onCompose}
|
|
data-mv-compose=""
|
|
data-tour="compose-button"
|
|
className={cn(
|
|
"flex items-center bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)] hover:shadow-md transition-shadow",
|
|
isCollapsed ? "w-14 justify-center" : "gap-3 ps-4 pe-6"
|
|
)}
|
|
title={t("compose_hint")}
|
|
aria-label={t("compose")}
|
|
>
|
|
<Pencil className="w-6 h-6 flex-shrink-0" />
|
|
{!isCollapsed && <span className="text-sm font-medium">{t("compose")}</span>}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!isCollapsed && <DemoBanner />}
|
|
{!isCollapsed && <VacationBanner />}
|
|
|
|
{/* Mailbox List */}
|
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
|
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
|
<div>
|
|
<SidebarSectionHeader
|
|
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
|
|
expanded={unifiedExpanded}
|
|
onToggle={toggleUnified}
|
|
isCollapsed={isCollapsed}
|
|
first
|
|
status={unifiedProgress && (
|
|
<span
|
|
className="ms-2 text-xs font-normal tabular-nums text-muted-foreground animate-pulse"
|
|
title={t("all_accounts_loading", unifiedProgress)}
|
|
data-testid="unified-accounts-loading"
|
|
>
|
|
<span aria-hidden="true">{unifiedProgress.loaded}/{unifiedProgress.total}</span>
|
|
<span className="sr-only">{t("all_accounts_loading", unifiedProgress)}</span>
|
|
</span>
|
|
)}
|
|
/>
|
|
{((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 (
|
|
<SidebarRow
|
|
key={unifiedId}
|
|
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
|
|
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 (
|
|
<SidebarRow
|
|
key={id}
|
|
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
|
|
label={label}
|
|
testName={id}
|
|
testMailboxId={id}
|
|
depth={0}
|
|
isSelected={isSelected}
|
|
unread={unread}
|
|
onClick={() => onMailboxSelect?.(id)}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{useMultiAccount ? (
|
|
accountGroups.map(({ account, isActive, tree }) => {
|
|
const expanded = !collapsedAccountGroups.has(account.id);
|
|
const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id;
|
|
return (
|
|
<div key={account.id} onContextMenu={isActive ? handleFoldersHeaderContextMenu : undefined}>
|
|
<SidebarSectionHeader
|
|
label={toUnicodeEmail(account.label || account.email || account.username)}
|
|
expanded={expanded}
|
|
onToggle={() => toggleAccountGroup(account.id)}
|
|
onSettings={isActive ? openFolderSettings : undefined}
|
|
settingsTitle={isActive ? t('settings') : undefined}
|
|
isCollapsed={isCollapsed}
|
|
first={!showUnified && account.id === connectedAccounts[0]?.id}
|
|
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
/>
|
|
{((expanded && !isCollapsed) || isCollapsed) && (
|
|
<>
|
|
{tree.length === 0 ? (
|
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
|
{!isCollapsed && t("loading_mailboxes")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{tree.filter(isSystemFolder).map((node) => (
|
|
<Fragment key={node.id}>
|
|
<MailboxTreeItem
|
|
node={node}
|
|
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={(mailboxId) =>
|
|
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}`)}
|
|
</Fragment>
|
|
))}
|
|
{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,
|
|
})}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
|
{!mvFlat && (
|
|
<SidebarSectionHeader
|
|
label={t("folders")}
|
|
expanded={foldersExpanded}
|
|
onToggle={toggleFolders}
|
|
onSettings={openFolderSettings}
|
|
settingsTitle={t('settings')}
|
|
isCollapsed={isCollapsed}
|
|
first={!showUnified}
|
|
/>
|
|
)}
|
|
{(mvFlat || (foldersExpanded && !isCollapsed) || isCollapsed) && (
|
|
<>
|
|
{mailboxes.length === 0 ? (
|
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
|
{!isCollapsed && t("loading_mailboxes")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{primaryTree.map((node) => (
|
|
<Fragment key={node.id}>
|
|
<MailboxTreeItem
|
|
node={node}
|
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onMailboxSelect}
|
|
onToggleExpand={handleToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={onUnreadFilterClick}
|
|
colorful={colorfulSidebarIcons}
|
|
onContextMenu={handleMailboxContextMenu}
|
|
/>
|
|
{node.role === 'drafts' && renderScheduledRow('scheduled')}
|
|
{/* Mountain View lists Starred right under the Inbox. */}
|
|
{mvFlat && node.role === 'inbox' && onOpenStarred && (
|
|
<SidebarRow
|
|
// Drawn like the "All starred" row it stands in for.
|
|
icon={<Star className={getIconClass(!selectedKeyword && selectedMailbox === CROSS_VIEW_IDS.starred, false, colorfulSidebarIcons)} />}
|
|
label={t("mailboxes.starred")}
|
|
testName="mv-starred"
|
|
depth={0}
|
|
isSelected={!selectedKeyword && selectedMailbox === CROSS_VIEW_IDS.starred}
|
|
onClick={onOpenStarred}
|
|
isCollapsed={false}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
{!primaryTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')}
|
|
{mvFlat && (
|
|
<SidebarRow
|
|
icon={moreExpanded
|
|
? <ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
: <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
|
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) => (
|
|
<MailboxTreeItem
|
|
key={node.id}
|
|
node={node}
|
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onMailboxSelect}
|
|
onToggleExpand={handleToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={onUnreadFilterClick}
|
|
colorful={colorfulSidebarIcons}
|
|
onContextMenu={handleMailboxContextMenu}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!useMultiAccount && renderCustomFolders(OWN_FOLDERS_KEY, customTree, {
|
|
selected: selectedKeyword ? "" : selectedMailbox,
|
|
onSelect: onMailboxSelect,
|
|
})}
|
|
|
|
{mvFlat && !useMultiAccount && (
|
|
<div data-mv-labels="">
|
|
<div className="flex items-center justify-between ps-[26px] pe-3 pt-4 pb-1">
|
|
<span className="text-base font-medium text-foreground">{t("labels")}</span>
|
|
{onCreateFolder && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onCreateFolder()}
|
|
className="grid place-items-center w-8 h-8 rounded-full text-foreground hover:bg-[var(--mv-nav-hover)] transition-colors"
|
|
title={t("create_label")}
|
|
aria-label={t("create_label")}
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{labelTree.map((node) => (
|
|
<MailboxTreeItem
|
|
key={node.id}
|
|
node={node}
|
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onMailboxSelect}
|
|
onToggleExpand={handleToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={onUnreadFilterClick}
|
|
colorful={colorfulSidebarIcons}
|
|
onContextMenu={handleMailboxContextMenu}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{tagsAboveShared && renderTags()}
|
|
|
|
{!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
|
|
<div>
|
|
<SidebarSectionHeader
|
|
label={t("shared")}
|
|
expanded={sharedExpanded}
|
|
onToggle={toggleShared}
|
|
isCollapsed={isCollapsed}
|
|
testId="section-shared"
|
|
/>
|
|
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
|
<>
|
|
{sharedAccounts.map((account) => {
|
|
const accountExpanded = expandedSharedAccounts.has(account.id);
|
|
const menuAccountId = sharedAccountMenuId(account.accountId);
|
|
return (
|
|
<div key={account.id}>
|
|
<SidebarSectionHeader
|
|
label={account.name}
|
|
expanded={accountExpanded}
|
|
onToggle={() => toggleSharedAccount(account.id)}
|
|
isCollapsed={isCollapsed}
|
|
sub
|
|
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
testId="section-shared-account"
|
|
onContextMenu={menuAccountId
|
|
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
|
|
: undefined}
|
|
/>
|
|
{accountExpanded && !isCollapsed && (
|
|
<>
|
|
{account.children.map((child) => (
|
|
<Fragment key={child.id}>
|
|
<MailboxTreeItem
|
|
node={child}
|
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
|
expandedFolders={expandedFolders}
|
|
onMailboxSelect={onMailboxSelect}
|
|
onToggleExpand={handleToggleExpand}
|
|
isCollapsed={isCollapsed}
|
|
onUnreadFilterClick={onUnreadFilterClick}
|
|
colorful={colorfulSidebarIcons}
|
|
onContextMenu={handleMailboxContextMenu}
|
|
/>
|
|
{child.role === 'drafts' && menuAccountId
|
|
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
|
</Fragment>
|
|
))}
|
|
{menuAccountId && !account.children.some((child) => child.role === 'drafts')
|
|
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!tagsAboveShared && renderTags()}
|
|
|
|
{!isCollapsed && <PluginSidebarSections mailboxId={selectedKeyword ? null : selectedMailbox || null} />}
|
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
|
</div>
|
|
|
|
<MailboxContextMenu
|
|
target={mailboxContextMenu.data}
|
|
position={mailboxContextMenu.position}
|
|
isOpen={mailboxContextMenu.isOpen}
|
|
onClose={closeMailboxContextMenu}
|
|
menuRef={mailboxMenuRef}
|
|
mailboxes={mailboxes}
|
|
onMarkFolderRead={onMarkFolderRead}
|
|
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
|
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
|
onEmptyFolder={onEmptyFolder}
|
|
onCreateSubfolder={onCreateSubfolder}
|
|
onCreateFolder={onCreateFolder}
|
|
onRenameFolder={onRenameFolder}
|
|
onDeleteFolder={onDeleteFolder}
|
|
onShareFolder={onShareFolder}
|
|
onImportEmail={onImportEmail}
|
|
onRefresh={onRefreshMailboxes}
|
|
onHideSharedAccount={hideSharedAccount}
|
|
notifications={notificationActions}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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);
|