Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/layout/sidebar.tsx # stores/email-store.ts # stores/settings-store.ts
This commit is contained in:
@@ -47,7 +47,7 @@ interface NavigationRailProps {
|
||||
activeAppId?: string | null;
|
||||
/**
|
||||
* If provided, intercepts the rail's built-in route navigation. Return
|
||||
* `true` to prevent the underlying `<Link>` from navigating — used by the
|
||||
* `true` to prevent the underlying `<Link>` from navigating - used by the
|
||||
* Pro interface to open the route as a tab instead. The visual rail is
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
+203
-76
@@ -52,6 +52,7 @@ 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 {
|
||||
@@ -76,6 +77,20 @@ interface SidebarProps {
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
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;
|
||||
@@ -664,10 +679,14 @@ export function Sidebar({
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
viewingAccountId = null,
|
||||
onAccountMailboxSelect,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
@@ -699,14 +718,33 @@ export function Sidebar({
|
||||
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();
|
||||
});
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
|
||||
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 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 showUnified = enableUnifiedMailbox && connectedAccounts.length > 1;
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
@@ -754,6 +792,24 @@ export function Sidebar({
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
|
||||
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-')
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
})
|
||||
: [];
|
||||
|
||||
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
|
||||
switch (role) {
|
||||
case 'inbox': return Inbox;
|
||||
@@ -833,6 +889,14 @@ export function Sidebar({
|
||||
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 { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||
@@ -870,32 +934,35 @@ export function Sidebar({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-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>
|
||||
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||
{!isEmbedded && (
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-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>
|
||||
<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>
|
||||
{!isCollapsed && !hideAccountSwitcher && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <DemoBanner />}
|
||||
{!isCollapsed && <VacationBanner />}
|
||||
@@ -936,56 +1003,116 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||
<SidebarSectionHeader
|
||||
label={t("folders")}
|
||||
expanded={foldersExpanded}
|
||||
onToggle={toggleFolders}
|
||||
onSettings={openFolderSettings}
|
||||
settingsTitle={t('settings')}
|
||||
isCollapsed={isCollapsed}
|
||||
first={!showUnified}
|
||||
/>
|
||||
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ownTree.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}
|
||||
/>
|
||||
))}
|
||||
{showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
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={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.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
{isActive && showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div onContextMenu={handleFoldersHeaderContextMenu}>
|
||||
<SidebarSectionHeader
|
||||
label={t("folders")}
|
||||
expanded={foldersExpanded}
|
||||
onToggle={toggleFolders}
|
||||
onSettings={openFolderSettings}
|
||||
settingsTitle={t('settings')}
|
||||
isCollapsed={isCollapsed}
|
||||
first={!showUnified}
|
||||
/>
|
||||
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ownTree.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}
|
||||
/>
|
||||
))}
|
||||
{showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedAccounts.length > 0 && (
|
||||
{!useMultiAccount && sharedAccounts.length > 0 && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("shared")}
|
||||
|
||||
Reference in New Issue
Block a user