import * as plugins from './plugins.js'; import type { IControllerBrowserViewState, IControllerBrowserTabState } from '../ts_interfaces/interfaces.js'; import type { ILiveBrowserDialogResponse } from '@push.rocks/smartbrowser/web'; interface IDialogPresentation { key: string; tabId: string; dialog: NonNullable; respond(input: ILiveBrowserDialogResponse): Promise; modal?: plugins.deesCatalog.DeesModal; input?: plugins.deesCatalog.DeesInputText; pending: boolean; error: string; } /** Presents website-owned dialogs; every response retains the original view and dialog identity. */ export class BrowserDialogPresenter { private current?: IDialogPresentation; public update(viewId: string, state: IControllerBrowserViewState, respond: IDialogPresentation['respond']): void { const tab = state.tabs.find(tab => tab.id === state.activeTabId); const dialog = tab?.dialog; const key = dialog ? JSON.stringify([viewId, tab!.id, dialog.id]) : undefined; if (key === this.current?.key) { if (this.current) this.current.respond = respond; return; } this.clear(); if (!key || !dialog || !tab) return; const record: IDialogPresentation = { key, tabId: tab.id, dialog, respond, pending: false, error: '' }; this.current = record; void this.show(record); } public clear(): void { const previous = this.current; this.current = undefined; void previous?.modal?.destroy(); } private async show(record: IDialogPresentation): Promise { if (this.current !== record) return; let origin = 'Website'; try { const url = new URL(record.dialog.url); origin = url.origin === 'null' ? url.protocol : url.origin; } catch { /* No trustworthy origin was supplied. */ } if (record.dialog.type === 'prompt' && !record.input) { record.input = new plugins.deesCatalog.DeesInputText(); record.input.label = 'Your response'; record.input.value = record.dialog.defaultPrompt; record.input.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.isComposing && !event.repeat) { event.preventDefault(); event.stopPropagation(); void this.answer(record, true); } }); } const modal = await plugins.deesCatalog.DeesModal.createAndShow({ heading: record.dialog.type === 'beforeunload' ? 'Leave this page?' : 'Website dialog', subheading: origin, width: 'small', content: plugins.deesElement.html`

${record.dialog.message}

${record.input ?? ''} ${record.error ? plugins.deesElement.html`

${record.error}

` : ''}`, menuOptions: [ ...(record.dialog.type === 'alert' ? [] : [{ name: record.dialog.type === 'beforeunload' ? 'Stay' : 'Cancel', action: async () => this.answer(record, false), }]), { name: record.dialog.type === 'beforeunload' ? 'Leave' : 'OK', action: async () => this.answer(record, true) }, ], }); if (this.current !== record) { await modal.destroy(); return; } record.modal = modal; modal.addEventListener('modal-close', () => { if (record.modal === modal) record.modal = undefined; if (this.current === record && !record.pending) void this.answer(record, false); }, { once: true }); if (record.input) { await record.input.updateComplete; await record.input.focus(); } } private async answer(record: IDialogPresentation, accept: boolean): Promise { if (this.current !== record || record.pending) return; record.pending = true; if (record.input) record.input.disabled = true; // The catalog types the value as `string | number | null`; only a `number` // input ever holds a non-string, and a prompt field is always text. The // response carries the entered text unchanged, whitespace included. const enteredValue = record.input?.value; const promptText = typeof enteredValue === 'string' ? enteredValue : ''; try { await record.respond({ tabId: record.tabId, dialogId: record.dialog.id, accept, ...(accept && record.input ? { promptText } : {}) }); if (this.current === record) this.clear(); } catch (error) { if (this.current !== record) return; record.error = error instanceof Error ? error.message : String(error); if (record.input) record.input.disabled = false; await record.modal?.destroy(); record.modal = undefined; await this.show(record); } finally { record.pending = false; } } }