feat: add Pro interface
This commit is contained in:
@@ -45,6 +45,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
@@ -75,6 +76,7 @@ export default function CalendarPage() {
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -1217,11 +1219,11 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail */}
|
||||
{!isMobile && (
|
||||
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -1413,7 +1415,7 @@ export default function CalendarPage() {
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<div className="shrink-0">
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
@@ -96,6 +97,7 @@ export default function ContactsPage() {
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -664,11 +666,11 @@ export default function ContactsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only */}
|
||||
{!isMobile && (
|
||||
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -818,7 +820,7 @@ export default function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -84,6 +85,7 @@ export default function FilesPage() {
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
@@ -375,10 +377,10 @@ export default function FilesPage() {
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{!isMobile && (
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -484,7 +486,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
|
||||
+79
-7
@@ -49,6 +49,8 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -217,6 +219,7 @@ export default function Home() {
|
||||
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
|
||||
const {
|
||||
emails,
|
||||
@@ -629,6 +632,65 @@ export default function Home() {
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
|
||||
// When this page is rendered inside the Pro shell as the Mail tab body,
|
||||
// we hoist every "show composer" intent into its own Pro tab and reset
|
||||
// the in-page state so the inline composer never appears in the Mail tab.
|
||||
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded || !showComposer) return;
|
||||
const replyTo = selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined;
|
||||
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||
let title = t('email_composer.new_message');
|
||||
if (baseSubject) {
|
||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
||||
} else if (effectiveMode === 'forward') {
|
||||
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
||||
} else {
|
||||
title = baseSubject;
|
||||
}
|
||||
}
|
||||
|
||||
useProTabStore.getState().openComposeTab({
|
||||
sessionId: composerSessionId + 1,
|
||||
mode: effectiveMode,
|
||||
replyTo,
|
||||
initialDraftText: composerDraftText,
|
||||
initialData: pendingDraft,
|
||||
sourceEmailId: selectedEmail?.id ?? null,
|
||||
title,
|
||||
});
|
||||
|
||||
setComposerSessionId((s) => s + 1);
|
||||
setShowComposer(false);
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerQuoteHeader(null);
|
||||
// We only react to the rising edge of `showComposer` here; the other
|
||||
// variables read above are captured-but-stale-safe because the next
|
||||
// open will fire a fresh effect with new values.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEmbedded, showComposer]);
|
||||
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
@@ -2022,7 +2084,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{isRateLimited && rateLimitSecondsLeft !== null && (
|
||||
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
||||
@@ -2038,8 +2100,8 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop Navigation Rail */}
|
||||
{!isMobile && !isTablet && (
|
||||
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
|
||||
{!isMobile && !isTablet && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -2385,6 +2447,14 @@ export default function Home() {
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
useProTabStore.getState().openEmailTab({
|
||||
accountId: email.accountId ?? '',
|
||||
emailId: email.id,
|
||||
mailboxId: selectedMailbox,
|
||||
title: email.subject?.trim() || t('email_composer.new_message'),
|
||||
});
|
||||
}) : undefined}
|
||||
onOpenConversation={handleOpenConversation}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
@@ -2490,8 +2560,10 @@ export default function Home() {
|
||||
shouldHideHorizontalViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
{showComposer ? (
|
||||
{/* Inline Composer - shown in viewer pane.
|
||||
In Pro/embedded mode the composer is hoisted into its own
|
||||
Pro tab (see the effect below), so we never render it inline. */}
|
||||
{(showComposer && !isEmbedded) ? (
|
||||
<ErrorBoundary
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
@@ -2653,8 +2725,8 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && (
|
||||
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ComponentType } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||
import { ProTabBar } from "@/components/pro/pro-tab-bar";
|
||||
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import MailPage from "@/app/[locale]/page";
|
||||
import CalendarPage from "@/app/[locale]/calendar/page";
|
||||
import ContactsPage from "@/app/[locale]/contacts/page";
|
||||
import FilesPage from "@/app/[locale]/files/page";
|
||||
import SettingsPage from "@/app/[locale]/settings/page";
|
||||
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||
|
||||
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
|
||||
mail: MailPage,
|
||||
calendar: CalendarPage,
|
||||
contacts: ContactsPage,
|
||||
files: FilesPage,
|
||||
settings: SettingsPage,
|
||||
};
|
||||
|
||||
export default function ProHome() {
|
||||
const t = useTranslations();
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
|
||||
);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
const {
|
||||
showAppsModal,
|
||||
inlineApp,
|
||||
loadedApps,
|
||||
handleManageApps,
|
||||
handleInlineApp,
|
||||
closeInlineApp,
|
||||
closeAppsModal,
|
||||
} = useSidebarApps();
|
||||
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const quota = useEmailStore((s) => s.quota);
|
||||
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
||||
|
||||
const tabs = useProTabStore((s) => s.tabs);
|
||||
const activeTabId = useProTabStore((s) => s.activeTabId);
|
||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||
const openTab = useProTabStore((s) => s.openTab);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||
|
||||
// Auth bootstrap (mirrors standard page)
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Pro is desktop-only — fall back to standard on mobile/tablet
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && (isMobile || isTablet) && typeof window !== "undefined") {
|
||||
window.location.replace("/");
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet]);
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0],
|
||||
[tabs, activeTabId]
|
||||
);
|
||||
|
||||
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||
openTab(itemId);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Only highlight the rail when an "app" tab is active; compose/email tabs
|
||||
// don't correspond to any rail item.
|
||||
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
|
||||
activeTab && (
|
||||
activeTab.kind === 'mail' || activeTab.kind === 'calendar'
|
||||
|| activeTab.kind === 'contacts' || activeTab.kind === 'files'
|
||||
|| activeTab.kind === 'settings'
|
||||
) ? activeTab.kind : null;
|
||||
|
||||
// Loading state (matches standard page exactly)
|
||||
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDesktop) return null;
|
||||
|
||||
return (
|
||||
<EmbeddedContext.Provider value={true}>
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Leftmost Navigation Rail — identical to the standard layout */}
|
||||
<div
|
||||
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
||||
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
onNavigate={handleRailNavigate}
|
||||
activeItemId={railActiveItemId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView
|
||||
apps={loadedApps}
|
||||
activeAppId={inlineApp.id}
|
||||
onClose={closeInlineApp}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!inlineApp && (
|
||||
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||
<ProTabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onActivate={setActiveTab}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
|
||||
{/* Tab bodies — every loaded tab stays mounted so flipping tabs
|
||||
preserves the page's internal state (selection, scroll,
|
||||
drafts). Inactive ones are hidden via CSS. */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{tabs
|
||||
.filter((tab) => loadedTabIds.includes(tab.id))
|
||||
.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
let body: React.ReactNode = null;
|
||||
if (tab.kind === 'compose' && tab.composeData) {
|
||||
body = <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
|
||||
} else if (tab.kind === 'email' && tab.emailData) {
|
||||
body = <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
|
||||
} else {
|
||||
const Component = APP_TAB_COMPONENTS[tab.kind];
|
||||
if (Component) body = <Component />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
{showAppsModal && (
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
)}
|
||||
</div>
|
||||
</EmbeddedContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
@@ -346,6 +347,7 @@ export default function SettingsPage() {
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
@@ -687,7 +689,7 @@ export default function SettingsPage() {
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
@@ -705,20 +707,22 @@ export default function SettingsPage() {
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
@@ -815,13 +819,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
@@ -829,21 +835,23 @@ export default function SettingsPage() {
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
{!isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
|
||||
@@ -21,6 +21,7 @@ interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
@@ -30,7 +31,7 @@ interface EmailListItemProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -125,6 +126,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
@@ -44,6 +45,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
@@ -447,6 +449,7 @@ export function EmailList({
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ThreadEmailItemProps {
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function ThreadEmailItem({
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
@@ -96,6 +98,12 @@ export function ThreadEmailItem({
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
@@ -51,7 +53,7 @@ interface SingleEmailItemProps {
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -146,6 +148,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -351,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -420,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
@@ -506,6 +516,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onEmailDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onEmailDoubleClick(latestEmail);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -762,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -45,6 +45,19 @@ interface NavigationRailProps {
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
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
|
||||
* Pro interface to open the route as a tab instead. The visual rail is
|
||||
* unchanged.
|
||||
*/
|
||||
onNavigate?: (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => boolean | void;
|
||||
/**
|
||||
* When `onNavigate` is in use, this controls which nav item the rail
|
||||
* highlights as active (since the URL alone no longer reflects the
|
||||
* active app).
|
||||
*/
|
||||
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
|
||||
}
|
||||
|
||||
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
|
||||
@@ -160,6 +173,8 @@ export function NavigationRail({
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
onNavigate,
|
||||
activeItemId,
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
@@ -259,18 +274,39 @@ export function NavigationRail({
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
// When the host (e.g. the Pro shell) takes over navigation via `onNavigate`,
|
||||
// it tells us which item is active; otherwise we infer it from the URL.
|
||||
const isSettingsActive = onNavigate
|
||||
? activeItemId === 'settings'
|
||||
: !activeAppId && pathname.startsWith("/settings");
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
const getIsActive = (href: string) => {
|
||||
const getIsActive = (href: string, itemId: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (onNavigate) {
|
||||
return activeItemId === itemId;
|
||||
}
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
|
||||
(e: React.MouseEvent) => {
|
||||
if (onNavigate) {
|
||||
const intercepted = onNavigate(itemId);
|
||||
if (intercepted !== false) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeAppId) {
|
||||
onCloseInlineApp?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<nav
|
||||
@@ -279,13 +315,13 @@ export function NavigationRail({
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = getIsActive(item.href);
|
||||
const isActive = getIsActive(item.href, item.id);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -375,7 +411,7 @@ export function NavigationRail({
|
||||
{/* Settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick('settings')}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -429,13 +465,13 @@ export function NavigationRail({
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = getIsActive(item.href);
|
||||
const isActive = getIsActive(item.href, item.id);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
@@ -555,7 +591,7 @@ export function NavigationRail({
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick('settings')}
|
||||
data-tour="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProComposeTabData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
|
||||
* draft autosave, and discard all flow through the shared `email-store`, so
|
||||
* the result is identical to composing inline in the mail page — the
|
||||
* composer is just hosted in its own tab instead of in the right pane.
|
||||
*/
|
||||
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
|
||||
// Keep stable references for the callbacks below so the composer's
|
||||
// `key={sessionId}` doesn't churn.
|
||||
const tabIdRef = useRef(tabId);
|
||||
tabIdRef.current = tabId;
|
||||
|
||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
sendData.body,
|
||||
sendData.cc,
|
||||
sendData.bcc,
|
||||
sendData.identityId,
|
||||
sendData.fromEmail,
|
||||
sendData.draftId,
|
||||
sendData.fromName,
|
||||
sendData.htmlBody,
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$answered');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
} else if (data.sourceEmailId && data.mode === 'forward') {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$forwarded');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $forwarded keyword:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the currently-active mail list so the new sent message /
|
||||
// updated keyword status shows up.
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
closeTab(tabIdRef.current);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
}, [closeTab]);
|
||||
|
||||
const handleDiscardDraft = useCallback(async (draftId: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.deleteEmail(draftId);
|
||||
} catch (error) {
|
||||
console.error('Failed to discard draft:', error);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleSaveState = useCallback((state: ComposerDraftData) => {
|
||||
updateComposeDraft(tabIdRef.current, state);
|
||||
// Keep the tab title in sync with the working subject.
|
||||
const subject = state.subject?.trim() || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, subject);
|
||||
}, [updateComposeDraft, updateTabTitle, t]);
|
||||
|
||||
// On first mount, ensure the tab title matches whatever subject we were
|
||||
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
|
||||
useEffect(() => {
|
||||
const initialSubject = data.initialData?.subject?.trim()
|
||||
?? data.replyTo?.subject
|
||||
?? '';
|
||||
const title = initialSubject || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, title);
|
||||
// Run once on mount only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
key={data.sessionId}
|
||||
mode={data.initialData?.mode ?? data.mode}
|
||||
replyTo={data.replyTo}
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProEmailTabData;
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
to: email.to,
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
attachments: email.attachments,
|
||||
messageId: email.messageId,
|
||||
inReplyTo: email.inReplyTo,
|
||||
references: email.references,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single email in its own Pro tab. Fetches the email content on
|
||||
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient —
|
||||
* it doesn't depend on what the Mail tab has selected.
|
||||
*/
|
||||
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const markAsRead = useEmailStore((s) => s.markAsRead);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const composerSessionIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
fetchEmailContent(client, data.emailId)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setEmail(loaded);
|
||||
if (loaded?.subject) {
|
||||
updateTabTitle(tabId, loaded.subject);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch email for Pro tab:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
|
||||
|
||||
const currentMailboxRole = useMemo(() => {
|
||||
if (!email) return undefined;
|
||||
const mb = email.mailboxIds
|
||||
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
|
||||
: undefined;
|
||||
return mb?.role;
|
||||
}, [email, mailboxes]);
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await deleteEmail(client, email.id);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
toast.error(tNotifications('error_deleting'));
|
||||
}
|
||||
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
const archiveMb = mailboxes.find((m) => m.role === 'archive');
|
||||
if (!archiveMb) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, archiveMb.id);
|
||||
toast.success(tNotifications('email_archived'));
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Archive failed:', err);
|
||||
}
|
||||
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleToggleStar = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await toggleStar(client, email.id);
|
||||
// Reflect locally — the viewer re-reads from email-store's selectedEmail
|
||||
// shape only for the mail tab; here we update our local copy too.
|
||||
setEmail((prev) => prev ? {
|
||||
...prev,
|
||||
keywords: {
|
||||
...prev.keywords,
|
||||
$flagged: !prev.keywords?.$flagged,
|
||||
},
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Toggle star failed:', err);
|
||||
}
|
||||
}, [client, email, toggleStar]);
|
||||
|
||||
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await markAsRead(client, emailId, read);
|
||||
setEmail((prev) => prev && prev.id === emailId ? {
|
||||
...prev,
|
||||
keywords: { ...prev.keywords, $seen: read },
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Mark as read failed:', err);
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, mailboxId);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err);
|
||||
}
|
||||
}, [client, email, moveToMailbox, closeTab, tabId]);
|
||||
|
||||
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleQuickReply = useCallback(async (body: string) => {
|
||||
handleReply(body);
|
||||
}, [handleReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={email}
|
||||
isLoading={isLoading}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onMoveToMailbox={handleMoveToMailbox}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split('@')[0]}
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
mailboxes={mailboxes}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProTab, ProTabKind } from "@/stores/pro-tab-store";
|
||||
|
||||
interface ProTabBarProps {
|
||||
tabs: ProTab[];
|
||||
activeTabId: string;
|
||||
onActivate: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
|
||||
mail: Mail,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
settings: Settings,
|
||||
compose: PenSquare,
|
||||
email: MailOpen,
|
||||
};
|
||||
|
||||
export function ProTabBar({ tabs, activeTabId, onActivate, onClose, className }: ProTabBarProps) {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = TAB_ICONS[tab.kind];
|
||||
const isActive = tab.id === activeTabId;
|
||||
const label = tab.title ?? tSidebar(tab.labelKey);
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onMouseDown={(e) => {
|
||||
// Middle-click closes the tab (when closeable) — matches browser-tab behavior.
|
||||
if (e.button === 1 && tab.closeable) {
|
||||
e.preventDefault();
|
||||
onClose(tab.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
// Equal-width tabs that grow up to 200px when there's room and
|
||||
// shrink down to ~64px when the bar would overflow — matches
|
||||
// browser/Thunderbird tab behaviour.
|
||||
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||
"border-r border-border first:border-l",
|
||||
isActive
|
||||
? "bg-background text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderRightColor: 'rgba(128, 128, 128, 0.3)', borderLeftColor: 'rgba(128, 128, 128, 0.3)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", isActive && "text-primary")} />
|
||||
<span className="truncate flex-1 min-w-0" title={label}>{label}</span>
|
||||
|
||||
{tab.closeable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
||||
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
)}
|
||||
aria-label={tSidebar("close") /* falls back gracefully if missing */}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute left-0 right-0 -bottom-px h-px bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -115,9 +117,10 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -188,6 +191,23 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<div className="flex items-center gap-3">
|
||||
{proInterface && isDesktop && (
|
||||
<Link
|
||||
href="/pro"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('pro_interface.open_label')}
|
||||
</Link>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
onChange={(v) => updateSetting('proInterface', v)}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* True when the surrounding shell (currently only Pro) is rendering this
|
||||
* page as a tab body rather than as the top-level route. Standard routes
|
||||
* read this to hide their own NavigationRail and let the shell own the
|
||||
* chrome.
|
||||
*
|
||||
* Provided via context by the Pro shell — no URL coupling, no iframe.
|
||||
*/
|
||||
export const EmbeddedContext = createContext<boolean>(false);
|
||||
|
||||
export function useIsEmbedded(): boolean {
|
||||
return useContext(EmbeddedContext);
|
||||
}
|
||||
@@ -851,6 +851,12 @@
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Colorful Sidebar Icons",
|
||||
"description": "Tint folder and tag icons by type (blue Inbox, red Junk, green Sent, etc.). Disable for a monochrome sidebar."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro Interface (Experimental)",
|
||||
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
|
||||
"open_label": "Open Pro Interface",
|
||||
"back_to_standard": "Back to standard"
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
|
||||
export type ProTabKind =
|
||||
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
|
||||
| 'compose' | 'email';
|
||||
|
||||
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
|
||||
/**
|
||||
* Mirror of `EmailComposer.replyTo` — kept as a structural type here so the
|
||||
* tab store doesn't take a runtime dependency on the composer module.
|
||||
*/
|
||||
export interface ProReplyContext {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
bcc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
attachments?: Array<{
|
||||
blobId: string; name?: string; type: string; size: number;
|
||||
cid?: string; disposition?: string;
|
||||
}>;
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
quoteHeaderHtml?: string;
|
||||
quoteHeaderText?: string;
|
||||
quoteWrapInBlockquote?: boolean;
|
||||
}
|
||||
|
||||
export interface ProComposeTabData {
|
||||
/** Stable session id; used by the composer for draft autosave keying. */
|
||||
sessionId: number;
|
||||
mode: ProComposerMode;
|
||||
replyTo?: ProReplyContext;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
/** The id of the source email when replying/forwarding (for $answered/$forwarded). */
|
||||
sourceEmailId?: string | null;
|
||||
/** Tab title derived on open; updated as the composer subject changes. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ProEmailTabData {
|
||||
accountId: string;
|
||||
emailId: string;
|
||||
mailboxId: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ProTab {
|
||||
id: string;
|
||||
kind: ProTabKind;
|
||||
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
|
||||
labelKey: string;
|
||||
/** Dynamic title for compose/email tabs (overrides labelKey when present). */
|
||||
title?: string;
|
||||
closeable: boolean;
|
||||
composeData?: ProComposeTabData;
|
||||
emailData?: ProEmailTabData;
|
||||
}
|
||||
|
||||
interface ProTabState {
|
||||
tabs: ProTab[];
|
||||
activeTabId: string;
|
||||
loadedTabIds: string[];
|
||||
|
||||
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
|
||||
openComposeTab: (data: ProComposeTabData) => string;
|
||||
openEmailTab: (data: ProEmailTabData) => string;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
moveTab: (fromIdx: number, toIdx: number) => void;
|
||||
/** Update the dynamic title of a tab (used by compose tabs as the subject changes). */
|
||||
updateTabTitle: (id: string, title: string) => void;
|
||||
/** Persist updated draft state for a compose tab — used by the composer's onSaveState. */
|
||||
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
|
||||
}
|
||||
|
||||
const TAB_BLUEPRINTS: Record<'mail' | 'calendar' | 'contacts' | 'files' | 'settings', { labelKey: string }> = {
|
||||
mail: { labelKey: 'mail' },
|
||||
calendar: { labelKey: 'calendar' },
|
||||
contacts: { labelKey: 'contacts' },
|
||||
files: { labelKey: 'files' },
|
||||
settings: { labelKey: 'settings' },
|
||||
};
|
||||
|
||||
const HOME_TAB: ProTab = {
|
||||
id: 'home-mail',
|
||||
kind: 'mail',
|
||||
labelKey: TAB_BLUEPRINTS.mail.labelKey,
|
||||
closeable: false,
|
||||
};
|
||||
|
||||
function makeId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export const useProTabStore = create<ProTabState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [HOME_TAB],
|
||||
activeTabId: HOME_TAB.id,
|
||||
loadedTabIds: [HOME_TAB.id],
|
||||
|
||||
openTab: (kind) => {
|
||||
const state = get();
|
||||
const existing = state.tabs.find((tab) => tab.kind === kind);
|
||||
if (existing) {
|
||||
if (state.activeTabId !== existing.id) {
|
||||
set({
|
||||
activeTabId: existing.id,
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const blueprint = TAB_BLUEPRINTS[kind];
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind,
|
||||
labelKey: blueprint.labelKey,
|
||||
closeable: true,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
openComposeTab: (data) => {
|
||||
const state = get();
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind: 'compose',
|
||||
labelKey: '',
|
||||
title: data.title,
|
||||
closeable: true,
|
||||
composeData: data,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
openEmailTab: (data) => {
|
||||
const state = get();
|
||||
// Focus an existing email tab for the same message instead of duplicating.
|
||||
const existing = state.tabs.find(
|
||||
(tab) => tab.kind === 'email'
|
||||
&& tab.emailData?.emailId === data.emailId
|
||||
&& tab.emailData?.accountId === data.accountId
|
||||
);
|
||||
if (existing) {
|
||||
if (state.activeTabId !== existing.id) {
|
||||
set({
|
||||
activeTabId: existing.id,
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind: 'email',
|
||||
labelKey: '',
|
||||
title: data.title,
|
||||
closeable: true,
|
||||
emailData: data,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const state = get();
|
||||
const tab = state.tabs.find((t) => t.id === id);
|
||||
if (!tab || !tab.closeable) return;
|
||||
|
||||
const idx = state.tabs.findIndex((t) => t.id === id);
|
||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
||||
|
||||
let newActive = state.activeTabId;
|
||||
if (state.activeTabId === id) {
|
||||
const neighbor = newTabs[idx] ?? newTabs[idx - 1] ?? newTabs[0];
|
||||
newActive = neighbor?.id ?? HOME_TAB.id;
|
||||
}
|
||||
|
||||
if (newTabs.length === 0) {
|
||||
set({
|
||||
tabs: [HOME_TAB],
|
||||
activeTabId: HOME_TAB.id,
|
||||
loadedTabIds: [HOME_TAB.id],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActive,
|
||||
loadedTabIds: newLoaded.includes(newActive) ? newLoaded : [...newLoaded, newActive],
|
||||
});
|
||||
},
|
||||
|
||||
setActiveTab: (id) => {
|
||||
const state = get();
|
||||
if (!state.tabs.some((t) => t.id === id)) return;
|
||||
if (state.activeTabId === id) return;
|
||||
set({
|
||||
activeTabId: id,
|
||||
loadedTabIds: state.loadedTabIds.includes(id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, id],
|
||||
});
|
||||
},
|
||||
|
||||
moveTab: (fromIdx, toIdx) => {
|
||||
const state = get();
|
||||
if (fromIdx === toIdx) return;
|
||||
if (fromIdx < 0 || fromIdx >= state.tabs.length) return;
|
||||
if (toIdx < 0 || toIdx >= state.tabs.length) return;
|
||||
const tabs = [...state.tabs];
|
||||
const [moved] = tabs.splice(fromIdx, 1);
|
||||
tabs.splice(toIdx, 0, moved);
|
||||
set({ tabs });
|
||||
},
|
||||
|
||||
updateTabTitle: (id, title) => {
|
||||
const state = get();
|
||||
const tabs = state.tabs.map((tab) =>
|
||||
tab.id === id ? { ...tab, title } : tab
|
||||
);
|
||||
set({ tabs });
|
||||
},
|
||||
|
||||
updateComposeDraft: (id, draft) => {
|
||||
const state = get();
|
||||
const tabs = state.tabs.map((tab) => {
|
||||
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
|
||||
return {
|
||||
...tab,
|
||||
composeData: { ...tab.composeData, initialData: draft },
|
||||
};
|
||||
});
|
||||
set({ tabs });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pro-tabs',
|
||||
version: 2,
|
||||
// Don't persist transient compose drafts in tab metadata — the composer's
|
||||
// own draft-store already handles that. Persisted email tabs are fine to
|
||||
// restore (the tab body refetches the email by id).
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.kind === 'compose'
|
||||
? { ...tab, composeData: undefined } // drop compose tabs on reload
|
||||
: tab
|
||||
).filter((tab) => tab.kind !== 'compose'),
|
||||
activeTabId: state.activeTabId,
|
||||
loadedTabIds: state.loadedTabIds,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
if (state.tabs.length === 0) {
|
||||
state.tabs = [HOME_TAB];
|
||||
state.activeTabId = HOME_TAB.id;
|
||||
state.loadedTabIds = [HOME_TAB.id];
|
||||
return;
|
||||
}
|
||||
if (!state.tabs.some((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = state.tabs[0].id;
|
||||
}
|
||||
if (!state.loadedTabIds.includes(state.activeTabId)) {
|
||||
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -190,6 +190,7 @@ interface SettingsState {
|
||||
showToolbarLabels: boolean;
|
||||
hideAccountSwitcher: boolean;
|
||||
showRailAccountList: boolean;
|
||||
proInterface: boolean;
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: boolean;
|
||||
@@ -352,6 +353,7 @@ const DEFAULT_SETTINGS = {
|
||||
showToolbarLabels: true,
|
||||
hideAccountSwitcher: false,
|
||||
showRailAccountList: false,
|
||||
proInterface: false,
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: false,
|
||||
@@ -510,6 +512,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
toolbarPosition: state.toolbarPosition,
|
||||
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||
showRailAccountList: state.showRailAccountList,
|
||||
proInterface: state.proInterface,
|
||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
|
||||
Reference in New Issue
Block a user