forked from
me.webbeef.org/browser.html
Rewild Your Web
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Task chooser UI component.
4// Listens for taskrequest events on navigator.embedder, displays a list of
5// matching task providers, and responds with the user's selection.
6// Handles both window mode (new webview) and inline mode (overlay dialog).
7
8import {
9 LitElement,
10 html,
11 css,
12} from "beaver://shared/third_party/lit/lit-all.min.js";
13
14export class TaskChooser extends LitElement {
15 static properties = {
16 open: { type: Boolean, reflect: true },
17 _taskName: { state: true },
18 _providers: { state: true },
19 _requestId: { state: true },
20 };
21
22 static styles = css`
23 :host {
24 display: none;
25 position: fixed;
26 inset: 0;
27 z-index: var(--z-modal);
28 }
29
30 :host([open]) {
31 display: flex;
32 }
33
34 .overlay {
35 position: absolute;
36 inset: 0;
37 background: var(--color-backdrop);
38 display: flex;
39 align-items: flex-end;
40 justify-content: center;
41 }
42
43 .panel {
44 background: var(--bg-surface);
45 border-radius: var(--radius-md) var(--radius-md) 0 0;
46 padding: var(--spacing-md);
47 width: 100%;
48 max-width: 400px;
49 max-height: 60vh;
50 overflow-y: auto;
51 box-shadow: 0 -4px 20px var(--color-shadow);
52 font-family: var(--font-family-base);
53 }
54
55 .title {
56 font-size: var(--font-size-menu);
57 font-weight: var(--font-weight-bold);
58 color: var(--color-text);
59 margin-bottom: var(--spacing-md);
60 }
61
62 .provider {
63 display: flex;
64 align-items: center;
65 gap: var(--spacing-md);
66 padding: var(--spacing-sm);
67 border-radius: var(--radius-sm);
68 cursor: pointer;
69 transition: background var(--transition-fast);
70 }
71
72 .provider:hover {
73 background: var(--bg-hover);
74 }
75
76 .provider-icon {
77 width: 24px;
78 height: 24px;
79 border-radius: var(--radius-sm);
80 object-fit: contain;
81 flex-shrink: 0;
82 }
83
84 .provider-title {
85 font-size: var(--font-size-menu);
86 font-weight: var(--font-weight-bold);
87 color: var(--color-text);
88 }
89
90 .provider-description {
91 font-size: var(--font-size-sm);
92 color: var(--color-text-secondary);
93 }
94
95 .provider-origin {
96 font-size: var(--font-size-xs);
97 color: var(--color-text-tertiary);
98 }
99
100 .cancel {
101 display: block;
102 width: 100%;
103 margin-top: var(--spacing-md);
104 padding: var(--spacing-sm);
105 border: 1px solid var(--color-border);
106 border-radius: var(--radius-sm);
107 background: var(--bg-surface);
108 color: var(--color-text);
109 font-size: var(--font-size-menu);
110 font-family: var(--font-family-base);
111 cursor: pointer;
112 text-align: center;
113 transition: background var(--transition-fast);
114 }
115
116 .cancel:hover {
117 background: var(--bg-hover);
118 }
119
120 .always-use {
121 display: flex;
122 align-items: center;
123 gap: var(--spacing-sm);
124 padding: var(--spacing-sm);
125 font-size: var(--font-size-sm);
126 color: var(--color-text-secondary);
127 cursor: pointer;
128 }
129 `;
130
131 constructor() {
132 super();
133 this.open = false;
134 this._taskName = "";
135 this._providers = [];
136 this._requestId = "";
137 this._selectedDisplay = null;
138 }
139
140 connectedCallback() {
141 super.connectedCallback();
142 if (navigator.embedder) {
143 this._onTaskRequest = (e) => this._handleTaskRequest(e);
144 navigator.embedder.addEventListener("taskrequest", this._onTaskRequest);
145
146 this._onOpenProvider = (e) => this._handleOpenProvider(e);
147 navigator.embedder.addEventListener(
148 "opentaskprovider",
149 this._onOpenProvider,
150 );
151
152 this._onProvidersUpdate = (e) => this._handleProvidersUpdate(e);
153 navigator.embedder.addEventListener(
154 "taskprovidersupdate",
155 this._onProvidersUpdate,
156 );
157 }
158 }
159
160 disconnectedCallback() {
161 super.disconnectedCallback();
162 if (navigator.embedder) {
163 if (this._onTaskRequest) {
164 navigator.embedder.removeEventListener(
165 "taskrequest",
166 this._onTaskRequest,
167 );
168 }
169 if (this._onOpenProvider) {
170 navigator.embedder.removeEventListener(
171 "opentaskprovider",
172 this._onOpenProvider,
173 );
174 }
175 if (this._onProvidersUpdate) {
176 navigator.embedder.removeEventListener(
177 "taskprovidersupdate",
178 this._onProvidersUpdate,
179 );
180 }
181 }
182 }
183
184 _handleProvidersUpdate(e) {
185 const { requestId, providers } = e.detail;
186 if (requestId !== this._requestId) return;
187
188 try {
189 const newProviders =
190 typeof providers === "string" ? JSON.parse(providers) : providers;
191 if (newProviders.length > 0) {
192 console.log(
193 `[TaskChooser] Remote providers update: +${newProviders.length}`,
194 );
195 this._providers = [...this._providers, ...newProviders];
196 }
197 } catch {
198 // Ignore parse errors.
199 }
200 }
201
202 _handleOpenProvider(e) {
203 const { requestId, url, title } = e.detail;
204 console.log(`[TaskChooser] Opening provider: ${title} (${url})`);
205
206 if (this._selectedDisplay === "inline") {
207 // Dispatch event to show inline provider on the caller's webview.
208 this.dispatchEvent(
209 new CustomEvent("show-inline-provider", {
210 bubbles: true,
211 composed: true,
212 detail: { requestId, url, title },
213 }),
214 );
215 } else {
216 // Dispatch event to index.js to open the provider in a new webview.
217 this.dispatchEvent(
218 new CustomEvent("open-task-provider", {
219 bubbles: true,
220 composed: true,
221 detail: { requestId, url, title },
222 }),
223 );
224 }
225 }
226
227 _handleTaskRequest(e) {
228 const { requestId, taskName, providers, defaultProvider } = e.detail;
229 console.log("[TaskChooser] Task request received:", taskName, requestId);
230
231 this._requestId = requestId;
232 this._taskName = taskName;
233 this._selectedDisplay = null;
234
235 try {
236 this._providers =
237 typeof providers === "string" ? JSON.parse(providers) : providers;
238 } catch {
239 this._providers = [];
240 }
241
242 if (this._providers.length === 0) {
243 console.log("[TaskChooser] No providers, cancelling");
244 this._respond(null);
245 return;
246 }
247
248 // If there's a default and it's in the provider list, auto-select it.
249 if (defaultProvider) {
250 const defaultProv = this._providers.find((p) => p.id === defaultProvider);
251 if (defaultProv) {
252 console.log("[TaskChooser] Using default provider:", defaultProv.title);
253 this._selectProvider(defaultProv);
254 return;
255 }
256 }
257
258 this.open = true;
259 }
260
261 _selectProvider(provider) {
262 console.log("[TaskChooser] Provider selected:", provider.id, provider.title);
263
264 // If "always use this" is checked, save the default.
265 const checkbox = this.shadowRoot?.querySelector("#always-use");
266 if (checkbox?.checked) {
267 navigator.embedder.setTaskDefault(
268 this._taskName,
269 provider.href,
270 provider.remote_peer_id || null,
271 );
272 }
273
274 // Store display mode for when opentaskprovider arrives.
275 this._selectedDisplay = provider.display === "Inline" ? "inline" : "window";
276 this._respond(provider.id);
277 this.open = false;
278 }
279
280 _cancel() {
281 console.log("[TaskChooser] Cancelled");
282 this._respond(null);
283 this.open = false;
284 }
285
286 _respond(providerId) {
287 if (this._requestId && navigator.embedder) {
288 navigator.embedder.respondToTaskRequest(this._requestId, providerId);
289 this._requestId = "";
290 }
291 }
292
293 render() {
294 if (!this.open) return html``;
295
296 // Provider chooser.
297 return html`
298 <div class="overlay" @click=${this._cancel}>
299 <div class="panel" @click=${(e) => e.stopPropagation()}>
300 <div class="title">${this._taskName} with...</div>
301 ${this._providers.map(
302 (p) => html`
303 <div class="provider" @click=${() => this._selectProvider(p)}>
304 ${p.icon
305 ? html`<img class="provider-icon" src=${p.icon} alt="" />`
306 : ""}
307 <div>
308 <div class="provider-title">${p.title}</div>
309 ${p.description
310 ? html`<div class="provider-description">
311 ${p.description}
312 </div>`
313 : ""}
314 <div class="provider-origin">
315 ${p.origin}${p.device_name
316 ? html` <span class="device-name">(${p.device_name})</span>`
317 : ""}
318 </div>
319 </div>
320 </div>
321 `,
322 )}
323 ${this._providers.length > 1
324 ? html`<label class="always-use">
325 <input type="checkbox" id="always-use" />
326 Always use the selected provider
327 </label>`
328 : ""}
329 <button class="cancel" @click=${this._cancel}>Cancel</button>
330 </div>
331 </div>
332 `;
333 }
334}
335
336customElements.define("task-chooser", TaskChooser);