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:
+10
-3
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -48,6 +48,7 @@ function resetStore() {
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -85,6 +85,7 @@ export type SlotName =
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'sidebar-widget'
|
||||
| 'email-detail-sidebar'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user