mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user