feat: add ui.openDialog + 'plugin-dialog' slot for large, clickable custom UI

Every existing plugin slot renders wherever it's placed in the page (a
toolbar row, an attachment's hover-action bar, etc.) and is constrained
by that spot's own CSS - fine for small controls, but there's no way
for a plugin to show a large custom UI (a graphical file browser, a
multi-step wizard) since even a position:fixed overlay rendered from
inside such a slot is clipped to the surrounding row's own layout, not
the real page (confirmed live building the webdav-storage plugin: a
folder browser rendered but was squeezed into an unusable sliver).

Adds:
- host-dialog.ts: new 'custom' DialogKind + awaitCustomDialog(), reusing
  the existing confirm/alert/prompt queue plumbing.
- host-api.ts: 'ui.openDialog' API method.
- runtime.tsx: sandbox-side api.ui.openDialog(opts) proxy.
- plugin-types.ts: new 'plugin-dialog' SlotName - a plugin implements
  this slot with its actual custom UI.
- plugin-dialog-host.tsx: renders the requested slot via the existing
  PluginIframeSlot INSIDE the dialog's real app-root fixed overlay
  (position: fixed; inset: 0, above everything) instead of the
  confirm/prompt form, for kind: 'custom'. Resolves via a generic
  onResult callback in extraProps (marshalled the same way
  composer-attachment-source's onAttach already is - no new plumbing
  needed there).

A plugin calls api.ui.openDialog({ slot: 'plugin-dialog', extraProps,
title, width }) and awaits the Promise, which resolves to whatever its
own slot component passes to props.onResult(), or null if the user
closes the dialog without doing so.
This commit is contained in:
bartfaizoli76
2026-09-05 18:57:49 +02:00
parent 06f51486e0
commit 2adc281a9d
5 changed files with 106 additions and 18 deletions
+33 -9
View File
@@ -8,6 +8,8 @@
import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
import { PluginIframeSlot } from './plugin-iframe-slot';
import type { SlotName } from '@/lib/plugin-types';
// Lightweight **bold** support in plugin dialog messages. Everything else is
// rendered literally (newlines come from the parent's white-space: pre-wrap).
@@ -39,7 +41,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0);
const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false);
const cancel = () => resolveHead(current?.kind === 'prompt' || current?.kind === 'custom' ? null : false);
const submitPrompt = () => { if (canSubmit) resolveHead(values); };
useEffect(() => {
@@ -48,9 +50,11 @@ export function PluginDialogHost(): React.JSX.Element | null {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
} else if (e.key === 'Enter' && !isPrompt) {
} else if (e.key === 'Enter' && (current?.kind === 'confirm' || current?.kind === 'alert')) {
// For prompts, Enter is handled by the form (so it respects required
// validation and works from within an input); non-prompt dialogs accept.
// validation and works from within an input); confirm/alert accept.
// Custom dialogs resolve only via their own onResult callback or the
// host's X/Escape - an Enter press has no defined meaning for them.
e.preventDefault();
resolveHead(true);
}
@@ -58,7 +62,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current, isPrompt, canSubmit, values]);
}, [current, canSubmit, values]);
if (!current) return null;
@@ -94,20 +98,40 @@ export function PluginDialogHost(): React.JSX.Element | null {
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 12,
padding: 20,
maxWidth: 480,
maxWidth: current.kind === 'custom' ? (current.width ?? 560) : 480,
maxHeight: current.kind === 'custom' ? '85vh' : undefined,
overflowY: current.kind === 'custom' ? 'auto' : undefined,
width: '92%',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}
>
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title}
</h2>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title}
</h2>
{current.kind === 'custom' && (
<button
type="button"
onClick={cancel}
aria-label="Close"
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 18, lineHeight: 1, padding: 0, color: 'inherit' }}
>
✕
</button>
)}
</div>
{current.message && (
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{renderMessage(current.message)}
</p>
)}
{isPrompt ? (
{current.kind === 'custom' ? (
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={{ ...(current.extraProps ?? {}), onResult: (value: unknown) => resolveHead(value) }}
/>
) : isPrompt ? (
<form onSubmit={(e) => { e.preventDefault(); submitPrompt(); }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
{fields.map((f, i) => (
+20 -1
View File
@@ -24,7 +24,7 @@ import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client';
import { suggestKeywordColor } from '../keyword-discovery';
import { MAX_KEYWORD_LENGTH } from '../keyword-nesting';
import { KEYWORD_PREFIX } from '../thread-utils';
import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog';
import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
import { AddressBook, ContactCard, Identity } from '../jmap/types';
@@ -131,6 +131,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'ui.confirm': null,
'ui.alert': null,
'ui.prompt': null,
'ui.openDialog': null,
'ui.rerenderEmail': null,
'ui.rerenderFetchedEmails': null,
'ui.openExternalUrl': null,
@@ -1506,6 +1507,24 @@ export async function dispatchApiCall(
fields,
});
}
case 'ui.openDialog': {
// Renders one of THIS plugin's own slots inside PluginDialogHost's
// real app-root overlay, instead of a fixed confirm/prompt form - see
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
// exists (a small toolbar/row slot can't show a large custom UI).
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
return awaitCustomDialog({
pluginId: plugin.id,
title: String(opts.title ?? plugin.name ?? ''),
message: '',
slot: opts.slot,
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
width: typeof opts.width === 'number' ? opts.width : undefined,
});
}
case 'ui.rerenderEmail': {
// Re-run the onRenderEmailBody hook for the currently open message. Used
// by crypto plugins after they change decryption state (e.g. an S/MIME key
+35 -7
View File
@@ -5,7 +5,7 @@
// at a time. Prompts collect one or more (optionally masked) text fields so a
// plugin never has to fall back to the sandbox-blocked `window.prompt`.
export type DialogKind = 'confirm' | 'alert' | 'prompt';
export type DialogKind = 'confirm' | 'alert' | 'prompt' | 'custom';
export interface PromptField {
/** Key the field's value is returned under. */
@@ -36,8 +36,22 @@ export interface DialogRequest {
danger?: boolean;
/** Fields to collect, for `kind === 'prompt'`. */
fields?: PromptField[];
/**
* For `kind === 'custom'`: the plugin's own slot name to render in place of
* the confirm/prompt body, and the props to pass it. Unlike every other
* slot mount, this one renders inside `PluginDialogHost`'s real
* `position: fixed; inset: 0` container at the app root - not a small,
* CSS-constrained row - so it's the only way a plugin can show a large,
* fully custom, genuinely clickable UI (see PR discussion / the
* webdav-storage plugin's folder browser for why this exists at all: a
* prompt-loop UI works but nobody wants to type folder numbers).
*/
slot?: string;
extraProps?: Record<string, unknown>;
/** Optional width override in px for the dialog box (custom kind only). */
width?: number;
/** Called when the dialog closes with its typed result (see DialogResult). */
resolve: (result: DialogResult) => void;
resolve: (result: unknown) => void;
}
const queue: DialogRequest[] = [];
@@ -64,16 +78,16 @@ export function head(): DialogRequest | null {
return queue[0] ?? null;
}
export function resolveHead(result: DialogResult): void {
export function resolveHead(result: unknown): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(result); } catch { /* ignore */ }
notify();
}
/** The "cancelled" result for a given dialog kind (null for prompt, else false). */
function cancelledResult(kind: DialogKind): DialogResult {
return kind === 'prompt' ? null : false;
/** The "cancelled" result for a given dialog kind (null for prompt/custom, else false). */
function cancelledResult(kind: DialogKind): unknown {
return kind === 'prompt' || kind === 'custom' ? null : false;
}
/** Cancel every pending dialog for a plugin (called on unload). */
@@ -111,6 +125,20 @@ export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise
*/
export function awaitPrompt(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<Record<string, string> | null> {
return new Promise((resolve) => {
enqueueDialog({ ...req, resolve: (r) => resolve(r && typeof r === 'object' ? r : null) });
enqueueDialog({ ...req, resolve: (r) => resolve(r && typeof r === 'object' ? (r as Record<string, string>) : null) });
});
}
/**
* Custom variant: renders the given plugin slot inside PluginDialogHost's
* real full-screen container instead of a confirm/prompt form. Resolves to
* whatever value the slot component passes to its `onResult` prop (an
* ordinary callback, marshalled across the iframe boundary the same way
* `composer-attachment-source`'s `onAttach` is), or `null` if the user
* closes the dialog (X button / Escape) without calling it.
*/
export function awaitCustomDialog(req: Omit<DialogRequest, 'id' | 'resolve' | 'kind'>): Promise<unknown> {
return new Promise((resolve) => {
enqueueDialog({ ...req, kind: 'custom', resolve });
});
}
+11
View File
@@ -327,6 +327,17 @@ function buildPluginApi(manifest: PluginManifest) {
cancelLabel?: string;
fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>;
}) => callApi('ui.prompt', [opts], 0) as Promise<Record<string, string> | null>,
/** Opens one of this plugin's OWN slots (see the 'plugin-dialog'
* SlotName) inside a real, app-root, full-size dialog - unlike every
* other slot, which renders wherever it's placed in the page and is
* constrained by that spot's own layout. Use this for anything that
* needs to be a large, genuinely clickable custom UI (a file browser,
* a multi-step form, etc.) rather than a toolbar/row-sized control.
* Resolves to whatever value the slot component passes to its
* `onResult` prop, or null if the user closes the dialog without
* calling it. No timeout - it waits for the user. */
openDialog: (opts: { title?: string; slot: string; extraProps?: Record<string, unknown>; width?: number }) =>
callApi('ui.openDialog', [opts], 0) as Promise<unknown>,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
rerenderEmail: () => callApi('ui.rerenderEmail', []) as Promise<void>,
+7 -1
View File
@@ -290,7 +290,13 @@ export type SlotName =
| 'admin-plugin-page'
| 'contact-cryptokeys'
| 'attachment-actions'
| 'composer-attachment-source';
| 'composer-attachment-source'
// Rendered inside a plugin-requested host dialog (see `ui.openDialog` /
// PluginDialogHost), NOT in-page like every other slot above - this is
// the only slot that renders inside the app-root fixed overlay instead of
// wherever the slot is placed in the page, so it's the one to use for a
// large, genuinely clickable custom UI a small toolbar/row slot can't fit.
| 'plugin-dialog';
export interface SlotRegistration {
pluginId: string;