'use client'; // Host-rendered modal for plugin-requested confirm/alert/prompt dialogs. // Subscribes to the host-dialog queue and renders the head request, one at // a time. Closing the modal advances the queue. Prompts collect one or more // (optionally masked) fields so plugins never fall back to `window.prompt`, // which the sandbox blocks. import React, { useEffect, useId, useMemo, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'next-intl'; import { cancelForPlugin, head, resolveDialog, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; import { usePluginStore } from '@/stores/plugin-store'; import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Puzzle, X } from '@/components/icons'; 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). // Splitting on the ** delimiter yields alternating plain/bold segments (odd // indices are bold). Plugins control these strings, so the delimiters balance. function renderMessage(message?: string): React.ReactNode { if (!message) return null; return message.split('**').map((seg, i) => i % 2 === 1 ? {seg} : {seg}, ); } export function PluginDialogHost(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); const t = useTranslations('plugins.dialog'); const titleId = useId(); const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current?.pluginId)?.name); const disablePlugin = usePluginStore((s) => s.disablePlugin); // Say so when a plugin was turned off for failing; otherwise it just // stops working with no hint why. useEffect(() => { const onDisabled = (e: Event) => { const id = (e as CustomEvent<{ pluginId: string }>).detail?.pluginId; const name = usePluginStore.getState().plugins.find((p) => p.id === id)?.name ?? id; if (id) toast.warning(t('toast_auto_disabled', { name })); }; window.addEventListener('plugin:auto-disabled', onDisabled); return () => window.removeEventListener('plugin:auto-disabled', onDisabled); }, [t]); const isPrompt = current?.kind === 'prompt'; const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); const hasPassword = fields.some((f) => f.type === 'password'); // Field values for a prompt, re-initialised whenever a new dialog surfaces. const [values, setValues] = useState>({}); useEffect(() => { if (!current) return; const init: Record = {}; for (const f of current.fields ?? []) init[f.name] = ''; setValues(init); }, [current]); // The custom slot's onResult answers its own dialog only; a stray second // call after it closed must not resolve the next one in the queue. const customProps = useMemo(() => { if (current?.kind !== 'custom') return undefined; const id = current.id; return { ...(current.extraProps ?? {}), onResult: (value: unknown) => resolveDialog(id, value) }; }, [current]); const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0); const cancel = () => resolveHead(current?.kind === 'prompt' || current?.kind === 'custom' ? null : false); const submitPrompt = () => { if (canSubmit) resolveHead(values); }; const turnOff = () => { if (!current) return; const id = current.pluginId; cancelForPlugin(id); disablePlugin(id); }; useEffect(() => { if (!current) return; function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } 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); 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); } } document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [current, canSubmit, values]); if (!current) return null; const name = pluginName ?? current.pluginId; const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? t('ok') : current.kind === 'prompt' ? t('submit') : t('confirm')); const cancelLabel = current.cancelLabel ?? t('cancel'); return (
{ if (e.target === e.currentTarget) cancel(); }} >
{/* Who is asking. Plugins choose the title and message, so the host names the plugin itself, in a place the plugin can't change. */}
{t('from_plugin', { name })} {current.kind === 'custom' && ( )}

{current.title}

{current.message && (

{renderMessage(current.message)}

)} {current.kind === 'custom' ? ( ) : isPrompt ? (
{ e.preventDefault(); submitPrompt(); }}>
{fields.map((f, i) => ( ))} {hasPassword && (

{t('password_note', { name })}

)}
) : (
{current.kind === 'confirm' && ( )}
)}
); }