Merge pull request #975 from bartfaizoli76/feature/plugin-dialog-open

feat: api.ui.openDialog + plugin-dialog slot for large, clickable custom UI
This commit is contained in:
Linus Rath
2026-09-07 17:42:42 +02:00
committed by GitHub
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;