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.
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
// How plugins' side panels are named in the rail beside the mail
|
|
// (api.ui.setSidePanel): a title and a host icon. The host draws the button
|
|
// and the panel's header itself; without a title it shows the plugin's name,
|
|
// without an icon a puzzle piece. `rail: false` leaves the button out: the
|
|
// plugin opens its panel itself (api.ui.openPanel / togglePanel), e.g. from
|
|
// a top bar action.
|
|
|
|
export interface SidePanelLook {
|
|
title?: string;
|
|
/** Name of a host icon (see components/icons), as for api.ui.setPages. */
|
|
icon?: string;
|
|
/** false: no button in the rail; the panel opens only on the plugin's request. */
|
|
rail?: boolean;
|
|
}
|
|
|
|
export const MAX_SIDE_PANEL_TITLE = 40;
|
|
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
|
|
|
let looks: ReadonlyMap<string, SidePanelLook> = new Map();
|
|
const listeners = new Set<() => void>();
|
|
|
|
function changed(): void {
|
|
for (const l of listeners) {
|
|
try { l(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/** Check and store a panel's title and icon (null clears both); throws with a message the plugin sees. */
|
|
export function setSidePanel(pluginId: string, value: unknown): void {
|
|
if (value === null || value === undefined) {
|
|
clearSidePanel(pluginId);
|
|
return;
|
|
}
|
|
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('ui.setSidePanel: expects { title, icon, rail } or null');
|
|
const raw = value as Record<string, unknown>;
|
|
const look: SidePanelLook = {};
|
|
if (raw.title !== undefined && raw.title !== null) {
|
|
if (typeof raw.title !== 'string') throw new Error('ui.setSidePanel: title must be a string');
|
|
// eslint-disable-next-line no-control-regex -- one line of text
|
|
const title = raw.title.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
if (title.length > MAX_SIDE_PANEL_TITLE) throw new Error(`ui.setSidePanel: title has at most ${MAX_SIDE_PANEL_TITLE} characters`);
|
|
if (title) look.title = title;
|
|
}
|
|
if (raw.icon !== undefined && raw.icon !== null) {
|
|
if (typeof raw.icon !== 'string' || !ICON_RE.test(raw.icon)) throw new Error('ui.setSidePanel: icon is invalid');
|
|
look.icon = raw.icon;
|
|
}
|
|
if (raw.rail !== undefined && raw.rail !== null) {
|
|
if (typeof raw.rail !== 'boolean') throw new Error('ui.setSidePanel: rail must be a boolean');
|
|
if (!raw.rail) look.rail = false;
|
|
}
|
|
looks = new Map(looks).set(pluginId, look);
|
|
changed();
|
|
}
|
|
|
|
/** Every plugin's title and icon; a new map after each change, the same one between. */
|
|
export function sidePanelLooks(): ReadonlyMap<string, SidePanelLook> {
|
|
return looks;
|
|
}
|
|
|
|
export function clearSidePanel(pluginId: string): void {
|
|
if (!looks.has(pluginId)) return;
|
|
const next = new Map(looks);
|
|
next.delete(pluginId);
|
|
looks = next;
|
|
changed();
|
|
}
|
|
|
|
export function subscribeSidePanels(listener: () => void): () => void {
|
|
listeners.add(listener);
|
|
return () => { listeners.delete(listener); };
|
|
}
|