diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index eb39244d..138c8717 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -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 ( -
+
- {/* Left Navigation Rail */} - {!isMobile && ( + {/* Left Navigation Rail (hidden when embedded in Pro shell) */} + {!isMobile && !isEmbedded && (
{ @@ -664,11 +666,11 @@ export default function ContactsPage() { }; return ( -
+
- {/* Navigation Rail - desktop only */} - {!isMobile && ( + {/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */} + {!isMobile && !isEmbedded && (
- {isMobile && ( + {isMobile && !isEmbedded && ( (() => loadFilesSettings().folderLayout); const hasFetched = useRef(false); @@ -375,10 +377,10 @@ export default function FilesPage() { if (!isAuthenticated) return null; return ( -
+
- {!isMobile && ( + {!isMobile && !isEmbedded && (
- {isMobile && ( + {isMobile && !isEmbedded && ( { + 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 ( -
+
{isRateLimited && rateLimitSecondsLeft !== null && (
@@ -2038,8 +2100,8 @@ export default function Home() {
)}
- {/* Desktop Navigation Rail */} - {!isMobile && !isTablet && ( + {/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */} + {!isMobile && !isTablet && !isEmbedded && (
{ + 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) ? ( { @@ -2653,8 +2725,8 @@ export default function Home() {
- {/* Bottom Navigation - mobile and tablet */} - {(isMobile || isTablet) && activeView !== "viewer" && ( + {/* Bottom Navigation - mobile and tablet (hidden when embedded) */} + {(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && ( > = { + 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 ( +
+
+
+

{t("common.loading")}

+
+
+ ); + } + + if (!isDesktop) return null; + + return ( + +
+
+ {/* Leftmost Navigation Rail — identical to the standard layout */} +
+ setShowShortcutsModal(true)} + onManageApps={handleManageApps} + onInlineApp={handleInlineApp} + onCloseInlineApp={closeInlineApp} + activeAppId={inlineApp?.id ?? null} + onNavigate={handleRailNavigate} + activeItemId={railActiveItemId} + /> +
+ + {inlineApp && ( + + )} + + {!inlineApp && ( +
+ + + {/* 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. */} +
+ {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 = ; + } else if (tab.kind === 'email' && tab.emailData) { + body = ; + } else { + const Component = APP_TAB_COMPONENTS[tab.kind]; + if (Component) body = ; + } + return ( +
+ {body} +
+ ); + })} +
+
+ )} +
+ + setShowShortcutsModal(false)} + /> + {showAppsModal && ( + + )} +
+
+ ); +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 32aad8d7..f910dc42 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -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 ( -
+
); } return ( -
+
- + {!isEmbedded && ( + + )}
); @@ -829,21 +835,23 @@ export default function SettingsPage() { // Desktop layout return ( -
+
-
- -
+ {!isEmbedded && ( +
+ +
+ )} {inlineApp && ( diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 6ea316b0..2fdd4172 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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)' }} > diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5c39f172..17f11b3d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bcc25b46..11cfd637 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -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)' }} > diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 34f67d5c..ca940644 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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( - 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( 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 onEmailSelect(latestEmail)} + onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} colorTag={colorTag} @@ -506,6 +516,12 @@ export const ThreadListItem = React.forwardRef { + 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 onEmailSelect(email)} + onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined} onContextMenu={onContextMenu} /> )) diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index d7a08cf1..1d1b05e2 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 `` 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 (