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 { 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 */}
<div
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
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
"md:flex-1 md:min-w-0 md:relative"
// Tablet/Desktop: relative
"md:relative"
)}
>
{/* Inline Composer - shown in viewer pane */}
+63 -1
View File
@@ -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<PostalMimeAttachment[]>([]);
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({
<div>
<PluginSlot name="email-banner" />
<PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */}
<div className="email-content-wrapper overflow-x-auto">
@@ -4691,6 +4701,58 @@ export function EmailViewer({
</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 */}
{contactSidebarEmail && !isMobileDevice && (
<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 */}
<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': [],
'composer-toolbar': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
+6
View File
@@ -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<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) => {
requirePermission(plugin, 'ui:context-menu');
const Component = () => {
+3
View File
@@ -49,6 +49,9 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
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);
+1
View File
@@ -85,6 +85,7 @@ export type SlotName =
| 'email-footer'
| 'composer-toolbar'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom';
+2 -2
View File
@@ -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:`;
+11 -1
View File
@@ -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<EmailStore>((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,
+5 -1
View File
@@ -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<SlotName, SlotRegistration[]> {
@@ -136,6 +136,10 @@ export const usePluginStore = create<PluginStoreState>()(
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