Files
webmail/components/plugins/plugin-side-panels.tsx
T
Jacob Zeitz b5558a66d8 feat(plugins): a side panel beside the mail for plugins
A frame slot `side-panel`: a plugin's panel right of the mail list and
reader, opened from a button in the panel rail (Mountain View's, or a slim
rail in the default layout while a plugin offers one). `ui.setSidePanel`
names its button; `ui.openPanel('side-panel')` opens it.

`ui.setSidePanel` takes `rail: false`: the rail draws no button for the
plugin's panel, which then opens only on the plugin's request
(openPanel / togglePanel('side-panel')), e.g. from a top bar action. A
rail left without buttons is not drawn, in the default layout and in
Mountain View's side panel alike.
2026-11-01 20:07:03 +01:00

194 lines
7.1 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
import { cn } from '@/lib/utils';
import { PluginIframeSlot } from './plugin-iframe-slot';
// The `side-panel` slot: one panel per plugin beside the mail, opened from
// its button in the rail at the right edge. Mountain View puts the buttons
// in its own side panel's rail (mv-side-panel.tsx); the default layout
// shows PluginSidePanel. At most one panel is open; which one is
// remembered in this browser. A plugin that set `rail: false` has no
// button: its panel opens only on its request; a rail without buttons is
// not drawn.
const STORAGE_KEY = 'plugin-side-panel-open';
export interface SidePanelEntry {
pluginId: string;
title: string;
Icon: AppIcon;
fill: boolean;
/** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */
inRail: boolean;
}
function readStored(): string | null {
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
}
function writeStored(pluginId: string | null): void {
try {
if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId);
else localStorage.removeItem(STORAGE_KEY);
} catch {
// Storage may be blocked; the panel is just shut next time.
}
}
/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */
export function usePluginSidePanelEntries(): SidePanelEntry[] {
const offers = usePluginSlotOffers('side-panel');
const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks);
return useMemo(() => {
return offers.map((offer) => {
const look = looks.get(offer.pluginId);
return {
pluginId: offer.pluginId,
title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
Icon: iconForName(look?.icon) ?? Puzzle,
fill: !!offer.fill,
inRail: look?.rail !== false,
};
});
}, [offers, looks]);
}
/**
* Which plugin's side panel is open (null: none), remembered in this
* browser, and the plugins' api.ui.openPanel('side-panel') requests;
* `onOpen` runs when a plugin's request opens its panel.
*/
export function usePluginSidePanel(onOpen?: () => void) {
const entries = usePluginSidePanelEntries();
const [chosen, setChosen] = useState<string | null>(readStored);
const [focusRequest, setFocusRequest] = useState(0);
// A plugin that went away (disabled, removed) leaves its panel shut.
const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null;
const choose = useCallback((pluginId: string | null) => {
setChosen(pluginId);
writeStored(pluginId);
}, []);
const state = useRef({ entries, open, onOpen });
state.current = { entries, open, onOpen };
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'side-panel') return null;
if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null;
const isOpen = state.current.open === request.pluginId;
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
if (wantOpen) {
choose(request.pluginId);
setFocusRequest((n) => n + 1);
state.current.onOpen?.();
} else if (isOpen) {
choose(null);
}
return wantOpen;
}), [choose]);
return { entries, open, choose, focusRequest };
}
/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */
export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
entry: SidePanelEntry;
focusRequest: number;
onClose: () => void;
}) {
const tCommon = useTranslations('common');
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
return (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
</div>
<div
data-plugin-slot="side-panel"
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
>
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
</div>
</>
);
}
/**
* The default layout's side panel: a slim rail at the right edge with a
* button per plugin, and the open plugin's panel left of it. Nothing
* without a plugin offering one.
*/
export function PluginSidePanel() {
const t = useTranslations('sidebar');
const { entries, open, choose, focusRequest } = usePluginSidePanel();
if (entries.length === 0) return null;
const active = entries.find((e) => e.pluginId === open) ?? null;
const inRail = entries.filter((e) => e.inRail);
return (
<>
{active && (
<aside
data-plugin-side-panel={active.pluginId}
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
aria-label={active.title}
>
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
</aside>
)}
{inRail.length > 0 && <nav
data-plugin-side-rail=""
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
aria-label={t('mv_side_panel')}
>
{inRail.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={open === entry.pluginId}
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
className="h-9 w-9 rounded-md hover:bg-muted"
pressedClassName="bg-muted text-foreground"
/>
))}
</nav>}
</>
);
}
/** A plugin's button in a side panel's rail. */
export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: {
entry: SidePanelEntry;
pressed: boolean;
onClick: () => void;
className?: string;
pressedClassName?: string;
}) {
const { Icon } = entry;
return (
<button
type="button"
onClick={onClick}
data-plugin-side-panel-button={entry.pluginId}
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
title={entry.title}
aria-label={entry.title}
aria-pressed={pressed}
>
<Icon className="h-5 w-5" />
</button>
);
}