mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
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.
194 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|