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 { 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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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': [],
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user