feat: add plugin ui.prompt dialog and first-class settings-section tabs
This commit is contained in:
@@ -8,7 +8,7 @@ import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { apiFetch } from '../browser-navigation';
|
||||
import { awaitDialog } from './host-dialog';
|
||||
import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
@@ -46,6 +46,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// ui - any plugin can ask the host to render a modal or open a URL.
|
||||
'ui.confirm': null,
|
||||
'ui.alert': null,
|
||||
'ui.prompt': null,
|
||||
'ui.openExternalUrl': null,
|
||||
};
|
||||
|
||||
@@ -364,6 +365,27 @@ export async function dispatchApiCall(
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.prompt': {
|
||||
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; fields?: PromptField[] };
|
||||
const fields: PromptField[] = Array.isArray(opts.fields)
|
||||
? opts.fields.map((f) => ({
|
||||
name: String(f.name),
|
||||
label: String(f.label),
|
||||
type: f.type === 'password' ? 'password' : 'text',
|
||||
placeholder: typeof f.placeholder === 'string' ? f.placeholder : undefined,
|
||||
required: !!f.required,
|
||||
}))
|
||||
: [];
|
||||
return awaitPrompt({
|
||||
pluginId: plugin.id,
|
||||
kind: 'prompt',
|
||||
title: String(opts.title ?? plugin.name ?? 'Enter details'),
|
||||
message: String(opts.message ?? ''),
|
||||
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
|
||||
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
case 'ui.openExternalUrl': {
|
||||
const url = String(args[0] ?? '');
|
||||
// Only http(s) - the sandbox should not be able to navigate the host
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
|
||||
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
|
||||
// dialog here and resolves the awaited Promise after the user clicks. The
|
||||
// `PluginDialogHost` component subscribes and renders one dialog at a time.
|
||||
// Process-wide queue for plugin-requested host dialogs (confirm / alert /
|
||||
// prompt). The sandboxed plugin posts a `ui.confirm`/`ui.prompt` API request;
|
||||
// the host enqueues a dialog here and resolves the awaited Promise after the
|
||||
// user acts. The `PluginDialogHost` component subscribes and renders one dialog
|
||||
// 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';
|
||||
export type DialogKind = 'confirm' | 'alert' | 'prompt';
|
||||
|
||||
export interface PromptField {
|
||||
/** Key the field's value is returned under. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** `password` masks the input; defaults to `text`. */
|
||||
type?: 'text' | 'password';
|
||||
placeholder?: string;
|
||||
/** Submit is blocked until every required field is non-empty. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* confirm/alert resolve to a boolean; prompt resolves to a name→value map on
|
||||
* submit, or `null` when cancelled.
|
||||
*/
|
||||
export type DialogResult = boolean | Record<string, string> | null;
|
||||
|
||||
export interface DialogRequest {
|
||||
id: string;
|
||||
@@ -15,8 +34,10 @@ export interface DialogRequest {
|
||||
cancelLabel?: string;
|
||||
/** When true, confirm button uses destructive styling. */
|
||||
danger?: boolean;
|
||||
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
|
||||
resolve: (ok: boolean) => void;
|
||||
/** Fields to collect, for `kind === 'prompt'`. */
|
||||
fields?: PromptField[];
|
||||
/** Called when the dialog closes with its typed result (see DialogResult). */
|
||||
resolve: (result: DialogResult) => void;
|
||||
}
|
||||
|
||||
const queue: DialogRequest[] = [];
|
||||
@@ -43,13 +64,18 @@ export function head(): DialogRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveHead(ok: boolean): void {
|
||||
export function resolveHead(result: DialogResult): void {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
try { entry.resolve(ok); } catch { /* ignore */ }
|
||||
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;
|
||||
}
|
||||
|
||||
/** Cancel every pending dialog for a plugin (called on unload). */
|
||||
export function cancelForPlugin(pluginId: string): void {
|
||||
let changed = false;
|
||||
@@ -57,7 +83,7 @@ export function cancelForPlugin(pluginId: string): void {
|
||||
if (queue[i].pluginId === pluginId) {
|
||||
const entry = queue[i];
|
||||
queue.splice(i, 1);
|
||||
try { entry.resolve(false); } catch { /* ignore */ }
|
||||
try { entry.resolve(cancelledResult(entry.kind)); } catch { /* ignore */ }
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -70,11 +96,21 @@ export function subscribe(listener: () => void): () => void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper used by host-api to convert an `enqueueDialog` call into a
|
||||
* Promise the plugin-side `await` can land on.
|
||||
* Internal helper used by host-api to convert a confirm/alert `enqueueDialog`
|
||||
* call into a boolean Promise the plugin-side `await` can land on.
|
||||
*/
|
||||
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
enqueueDialog({ ...req, resolve });
|
||||
enqueueDialog({ ...req, resolve: (r) => resolve(r === true) });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt variant of `awaitDialog`: resolves to the collected name→value map on
|
||||
* submit, or `null` when the user cancels/dismisses.
|
||||
*/
|
||||
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) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ export const API_METHODS = [
|
||||
'jmap.fetchBlob', 'jmap.sendRaw',
|
||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
||||
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.openExternalUrl',
|
||||
] as const;
|
||||
|
||||
export type ApiMethod = (typeof API_METHODS)[number];
|
||||
|
||||
@@ -203,6 +203,16 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||
/** Opens a host-rendered prompt collecting one or more (optionally masked)
|
||||
* fields. Resolves to a name→value map on submit, or null if cancelled.
|
||||
* No timeout. */
|
||||
prompt: (opts: {
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
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 an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
|
||||
Reference in New Issue
Block a user