feat(plugins): resizable collapsible detail sidebar, auto-init on refresh

- Add email-detail-sidebar slot type for right-side plugin panels
- Plugin sidebar inside EmailViewer with ResizeHandle (200-500px, drag/dbl-click reset)
- Collapse/expand toggle with PanelRightClose/PanelRightOpen icons
- Call initializePlugins() on mount so plugins survive page refresh
- Fix plugin loader: call exposePluginExternals() before loading
- Fix CSP: add blob: to script-src for plugin bundle loading
- Fix enablePlugin: wire slot registration bridge before loading
- Fix email-banner PluginSlot: pass email via extraProps
- Fix sidebar-widget PluginSlot: move inside scrollable area
- Emit emailHooks.onEmailOpen/onEmailClose from selectEmail()
This commit is contained in:
Linus Rath
2026-03-25 00:44:05 +01:00
parent 29a222eef4
commit 54981950b0
10 changed files with 104 additions and 10 deletions
+10 -3
View File
@@ -45,6 +45,8 @@ import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, Rotate
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
export default function Home() { export default function Home() {
const t = useTranslations(); const t = useTranslations();
@@ -266,6 +268,11 @@ export default function Home() {
}); });
}, [checkAuth]); }, [checkAuth]);
// Initialize plugins on mount (re-activates enabled plugins after refresh)
useEffect(() => {
usePluginStore.getState().initializePlugins();
}, []);
// Hydrate persisted column widths from localStorage // Hydrate persisted column widths from localStorage
useEffect(() => { useEffect(() => {
try { try {
@@ -1463,12 +1470,12 @@ export default function Home() {
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */} {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
<div <div
className={cn( className={cn(
"flex flex-col h-full bg-background", "flex flex-col h-full bg-background flex-1 min-w-0",
// Mobile: full screen overlay when active // Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30", "max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden", isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects // Tablet/Desktop: relative
"md:flex-1 md:min-w-0 md:relative" "md:relative"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane */}
+63 -1
View File
@@ -57,6 +57,7 @@ import {
MapPin, MapPin,
StickyNote, StickyNote,
PanelRightClose, PanelRightClose,
PanelRightOpen,
Send, Send,
FolderInput, FolderInput,
Inbox, Inbox,
@@ -96,6 +97,8 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef"; import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { ResizeHandle } from "@/components/layout/resize-handle";
interface EmailViewerProps { interface EmailViewerProps {
email: Email | null; email: Email | null;
@@ -948,6 +951,13 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]); const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); 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 // Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => { useLayoutEffect(() => {
smimeStore.load(); smimeStore.load();
@@ -4445,7 +4455,7 @@ export function EmailViewer({
<div> <div>
<PluginSlot name="email-banner" /> <PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */} {/* Email Body */}
<div className="email-content-wrapper overflow-x-auto"> <div className="email-content-wrapper overflow-x-auto">
@@ -4691,6 +4701,58 @@ export function EmailViewer({
</nav> </nav>
)} )}
{/* Plugin Detail Sidebar - resizable, collapsible */}
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
{detailSidebarCollapsed && (
<div className="flex flex-col items-center border-l border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(false)}
className="h-8 w-8 m-1"
aria-label="Expand panel"
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!detailSidebarCollapsed && (
<>
<ResizeHandle
onResizeStart={() => { 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)}
/>
<div
className="flex flex-col h-full border-l border-border bg-background overflow-hidden"
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(true)}
className="h-7 w-7"
aria-label="Collapse panel"
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
</div>
</div>
</>
)}
</>
)}
{/* Contact Detail Sidebar - desktop only */} {/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && ( {contactSidebarEmail && !isMobileDevice && (
<ContactSidebarPanel <ContactSidebarPanel
+2 -2
View File
@@ -657,9 +657,9 @@ export function Sidebar({
)} )}
</> </>
)} )}
</div>
<PluginSlot name="sidebar-widget" /> {!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
{/* Compose Button */} {/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}> <div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
+1
View File
@@ -48,6 +48,7 @@ function resetStore() {
'email-footer': [], 'email-footer': [],
'composer-toolbar': [], 'composer-toolbar': [],
'sidebar-widget': [], 'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [], 'settings-section': [],
'context-menu-email': [], 'context-menu-email': [],
'navigation-rail-bottom': [], 'navigation-rail-bottom': [],
+6
View File
@@ -113,6 +113,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable; registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable; registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable; registerSidebarWidget: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => 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<Record<string, unknown>>, widget.order ?? 100); return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
}, },
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerContextMenuItem: (item: ContextMenuItem) => { registerContextMenuItem: (item: ContextMenuItem) => {
requirePermission(plugin, 'ui:context-menu'); requirePermission(plugin, 'ui:context-menu');
const Component = () => { const Component = () => {
+3
View File
@@ -49,6 +49,9 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
return; return;
} }
// Ensure React/ReactDOM are exposed before any plugin module evaluates
exposePluginExternals();
try { try {
// 1. Read bundle from IndexedDB // 1. Read bundle from IndexedDB
const code = await pluginStorage.getCode(plugin.id); const code = await pluginStorage.getCode(plugin.id);
+1
View File
@@ -85,6 +85,7 @@ export type SlotName =
| 'email-footer' | 'email-footer'
| 'composer-toolbar' | 'composer-toolbar'
| 'sidebar-widget' | 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section' | 'settings-section'
| 'context-menu-email' | 'context-menu-email'
| 'navigation-rail-bottom'; | 'navigation-rail-bottom';
+2 -2
View File
@@ -9,8 +9,8 @@ export function proxy(request: NextRequest) {
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const scriptSrc = isDev const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval'` ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' 'nonce-${nonce}'`; : `'self' 'nonce-${nonce}' blob:`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`; const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
+11 -1
View File
@@ -4,6 +4,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
interface EmailStore { interface EmailStore {
emails: Email[]; emails: Email[];
@@ -159,7 +160,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setEmails: (emails) => set({ emails }), setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }), 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({ selectKeyword: (keyword) => set({
selectedKeyword: keyword, selectedKeyword: keyword,
selectedEmail: null, selectedEmail: null,
+5 -1
View File
@@ -19,7 +19,7 @@ import { removeAllPluginHooks } from '@/lib/plugin-hooks';
const SLOT_NAMES: SlotName[] = [ const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', '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<SlotName, SlotRegistration[]> { function emptySlots(): Record<SlotName, SlotRegistration[]> {
@@ -136,6 +136,10 @@ export const usePluginStore = create<PluginStoreState>()(
const plugin = plugins.find(p => p.id === id); const plugin = plugins.find(p => p.id === id);
if (!plugin) return; if (!plugin) return;
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
setSlotRegistrationBridge(get().registerSlot);
set({ set({
plugins: plugins.map(p => plugins: plugins.map(p =>
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p