diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index def07bca..fa929acf 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -45,6 +45,8 @@ import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, Rotate import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; +import { usePluginStore } from "@/stores/plugin-store"; + export default function Home() { const t = useTranslations(); @@ -266,6 +268,11 @@ export default function Home() { }); }, [checkAuth]); + // Initialize plugins on mount (re-activates enabled plugins after refresh) + useEffect(() => { + usePluginStore.getState().initializePlugins(); + }, []); + // Hydrate persisted column widths from localStorage useEffect(() => { try { @@ -1463,12 +1470,12 @@ export default function Home() { {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
{/* Inline Composer - shown in viewer pane */} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 75be5bd6..4a14d1a0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -57,6 +57,7 @@ import { MapPin, StickyNote, PanelRightClose, + PanelRightOpen, Send, FolderInput, Inbox, @@ -96,6 +97,8 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { usePluginStore } from "@/stores/plugin-store"; +import { ResizeHandle } from "@/components/layout/resize-handle"; interface EmailViewerProps { email: Email | null; @@ -948,6 +951,13 @@ export function EmailViewer({ const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); + // Plugin detail sidebar state + const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']); + const hasDetailSidebar = detailSlots && detailSlots.length > 0; + const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false); + const [detailSidebarWidth, setDetailSidebarWidth] = useState(280); + const detailSidebarWidthRef = useRef(280); + // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { smimeStore.load(); @@ -4445,7 +4455,7 @@ export function EmailViewer({
- + {/* Email Body */}
@@ -4691,6 +4701,58 @@ export function EmailViewer({ )} + {/* Plugin Detail Sidebar - resizable, collapsible */} + {hasDetailSidebar && !isMobile && ( + <> + {/* Collapse toggle when sidebar is collapsed */} + {detailSidebarCollapsed && ( +
+ +
+ )} + {!detailSidebarCollapsed && ( + <> + { detailSidebarWidthRef.current = detailSidebarWidth; }} + onResize={(delta) => { + const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta)); + setDetailSidebarWidth(newWidth); + }} + onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }} + onDoubleClick={() => setDetailSidebarWidth(280)} + /> +
+
+ +
+
+ +
+
+ + )} + + )} + {/* Contact Detail Sidebar - desktop only */} {contactSidebarEmail && !isMobileDevice && ( )} -
- + {!isCollapsed && } +
{/* Compose Button */}
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index dbbbc378..3f1f9301 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -48,6 +48,7 @@ function resetStore() { 'email-footer': [], 'composer-toolbar': [], 'sidebar-widget': [], + 'email-detail-sidebar': [], 'settings-section': [], 'context-menu-email': [], 'navigation-rail-bottom': [], diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index cc02b017..9831d61d 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -113,6 +113,7 @@ export interface PluginAPI { registerSettingsSection: (section: SettingsSection) => Disposable; registerComposerAction: (action: ComposerAction) => Disposable; registerSidebarWidget: (widget: SidebarWidget) => Disposable; + registerDetailSidebar: (widget: SidebarWidget) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerNavigationRailItem: (component: React.ComponentType) => Disposable; }; @@ -556,6 +557,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100); }, + registerDetailSidebar: (widget: SidebarWidget) => { + requirePermission(plugin, 'ui:sidebar-widget'); + return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100); + }, + registerContextMenuItem: (item: ContextMenuItem) => { requirePermission(plugin, 'ui:context-menu'); const Component = () => { diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 98873026..9dc835b3 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -49,6 +49,9 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise { return; } + // Ensure React/ReactDOM are exposed before any plugin module evaluates + exposePluginExternals(); + try { // 1. Read bundle from IndexedDB const code = await pluginStorage.getCode(plugin.id); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index d679f898..05c8a6a6 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -85,6 +85,7 @@ export type SlotName = | 'email-footer' | 'composer-toolbar' | 'sidebar-widget' + | 'email-detail-sidebar' | 'settings-section' | 'context-menu-email' | 'navigation-rail-bottom'; diff --git a/proxy.ts b/proxy.ts index bbf5c4f9..01a00312 100644 --- a/proxy.ts +++ b/proxy.ts @@ -9,8 +9,8 @@ export function proxy(request: NextRequest) { const isDev = process.env.NODE_ENV === "development"; const scriptSrc = isDev - ? `'self' 'nonce-${nonce}' 'unsafe-eval'` - : `'self' 'nonce-${nonce}'`; + ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:` + : `'self' 'nonce-${nonce}' blob:`; const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`; diff --git a/stores/email-store.ts b/stores/email-store.ts index 675e126b..d347c6c4 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -4,6 +4,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useSettingsStore } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; +import { emailHooks } from "@/lib/plugin-hooks"; interface EmailStore { emails: Email[]; @@ -159,7 +160,16 @@ export const useEmailStore = create((set, get) => ({ setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), - selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }), + selectEmail: (email) => { + const prev = get().selectedEmail; + set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }); + if (prev && (!email || email.id !== prev.id)) { + emailHooks.onEmailClose.emitSync(prev); + } + if (email && (!prev || email.id !== prev.id)) { + emailHooks.onEmailOpen.emitSync(email); + } + }, selectKeyword: (keyword) => set({ selectedKeyword: keyword, selectedEmail: null, diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 86bd4e60..16d2c5cc 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -19,7 +19,7 @@ import { removeAllPluginHooks } from '@/lib/plugin-hooks'; const SLOT_NAMES: SlotName[] = [ 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', - 'sidebar-widget', 'settings-section', 'context-menu-email', 'navigation-rail-bottom', + 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom', ]; function emptySlots(): Record { @@ -136,6 +136,10 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; + // Ensure bridges are wired before loading (may not have run initializePlugins yet) + setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus }); + setSlotRegistrationBridge(get().registerSlot); + set({ plugins: plugins.map(p => p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p