From 2adc281a9d879437ba36fb815db60d6e124a26fd Mon Sep 17 00:00:00 2001 From: bartfaizoli76 Date: Sat, 5 Sep 2026 13:49:19 +0200 Subject: [PATCH] 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. --- components/plugins/plugin-dialog-host.tsx | 42 ++++++++++++++++++----- lib/plugin-sandbox/host-api.ts | 21 +++++++++++- lib/plugin-sandbox/host-dialog.ts | 42 +++++++++++++++++++---- lib/plugin-sandbox/runtime.tsx | 11 ++++++ lib/plugin-types.ts | 8 ++++- 5 files changed, 106 insertions(+), 18 deletions(-) diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 54c6bce0b..1a9d3a650 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -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)', }} > -

- {current.title} -

+
+

+ {current.title} +

+ {current.kind === 'custom' && ( + + )} +
{current.message && (

{renderMessage(current.message)}

)} - {isPrompt ? ( + {current.kind === 'custom' ? ( + resolveHead(value) }} + /> + ) : isPrompt ? (
{ e.preventDefault(); submitPrompt(); }}>
{fields.map((f, i) => ( diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 46511d708..6393177e4 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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 = { '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; 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 diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts index aae595c4f..68159cefa 100644 --- a/lib/plugin-sandbox/host-dialog.ts +++ b/lib/plugin-sandbox/host-dialog.ts @@ -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; + /** 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): Promise */ export function awaitPrompt(req: Omit): Promise | 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) : 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): Promise { + return new Promise((resolve) => { + enqueueDialog({ ...req, kind: 'custom', resolve }); }); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 6e9a5f297..56b36383c 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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 | 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; width?: number }) => + callApi('ui.openDialog', [opts], 0) as Promise, /** 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, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 2332071b3..85c9f6180 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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;