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:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+1 -1
View File
@@ -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
View File
@@ -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")}