@hosty-sdk/app 0.35.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -6
- package/dist/feedback-audio.d.ts +3 -0
- package/dist/feedback-audio.js +40 -0
- package/dist/feedback-capture.d.ts +3 -0
- package/dist/feedback-capture.js +80 -0
- package/dist/feedback-draft.d.ts +20 -0
- package/dist/feedback-draft.js +104 -0
- package/dist/feedback-react.d.ts +4 -0
- package/dist/feedback-react.js +390 -0
- package/dist/feedback-server.d.ts +31 -0
- package/dist/feedback-server.js +48 -0
- package/dist/feedback-speech.d.ts +6 -0
- package/dist/feedback-speech.js +119 -0
- package/dist/feedback-types.d.ts +122 -0
- package/dist/feedback-types.js +9 -0
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -21
- package/dist/overlay-react.js +3 -2
- package/dist/permissions-react.js +1 -3
- package/dist/permissions.js +3 -3
- package/dist/providers-server.d.ts +2 -0
- package/dist/providers-server.js +13 -0
- package/dist/providers.d.ts +2 -2
- package/dist/sandbox-server.d.ts +1 -115
- package/dist/sandbox-server.js +0 -85
- package/dist/server.d.ts +0 -3
- package/dist/server.js +3 -15
- package/dist/session-server.d.ts +0 -2
- package/dist/session-server.js +4 -9
- package/dist/tasks-server.d.ts +33 -0
- package/dist/tasks-server.js +57 -0
- package/dist/tasks.d.ts +148 -0
- package/dist/tasks.js +2 -0
- package/dist/ui-frame.js +1 -1
- package/dist/ui-react.js +2 -1
- package/dist/ui-server.d.ts +0 -2
- package/dist/ui-server.js +39 -6
- package/package.json +17 -1
- package/dist/sandbox-identity.d.ts +0 -10
- package/dist/sandbox-identity.js +0 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { APP_SESSION_RESTORED, appFetch } from "./browser-auth.js";
|
|
6
|
+
import { SdkChromeStyles } from "./sdk-chrome.js";
|
|
7
|
+
import { blankDraft, deleteDraft, draftKey, makeNote, readDraft, saveDraft, submission } from "./feedback-draft.js";
|
|
8
|
+
import { FeedbackSpeech } from "./feedback-speech.js";
|
|
9
|
+
import { captureStill, cropStill } from "./feedback-capture.js";
|
|
10
|
+
function useBlobUrl(blob) {
|
|
11
|
+
const [url, setUrl] = useState("");
|
|
12
|
+
useEffect(() => { if (!blob) {
|
|
13
|
+
setUrl("");
|
|
14
|
+
return;
|
|
15
|
+
} const value = URL.createObjectURL(blob); setUrl(value); return () => URL.revokeObjectURL(value); }, [blob]);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
function ImagePreview({ blob }) { const url = useBlobUrl(blob); return url ? _jsx("img", { src: url, alt: "Included screenshot" }) : null; }
|
|
19
|
+
const rect = (b) => ({ x: b.x, y: b.y, width: b.width, height: b.height });
|
|
20
|
+
const viewport = () => ({ width: innerWidth, height: innerHeight, scale: devicePixelRatio });
|
|
21
|
+
const errorMessage = (e) => e instanceof Error ? e.message : "Feedback could not be saved.";
|
|
22
|
+
export function FeedbackCapture({ active, uiPath = "/api/hosty/ui" }) {
|
|
23
|
+
const [capabilities, setCapabilities] = useState(null);
|
|
24
|
+
const [unavailable, setUnavailable] = useState("");
|
|
25
|
+
const [retry, setRetry] = useState(0);
|
|
26
|
+
useEffect(() => { const refresh = () => setRetry(r => r + 1); window.addEventListener(APP_SESSION_RESTORED, refresh); return () => window.removeEventListener(APP_SESSION_RESTORED, refresh); }, []);
|
|
27
|
+
const path = uiPath.replace(/\/$/, "") + "/feedback";
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setCapabilities(null);
|
|
30
|
+
setUnavailable("");
|
|
31
|
+
if (!active)
|
|
32
|
+
return;
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
void appFetch(path, { signal: controller.signal }).then(async (response) => {
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(response.status === 404 ? "Update Core and this app to enable feedback." : "Feedback is unavailable. Retry when access is restored.");
|
|
37
|
+
const value = await response.json();
|
|
38
|
+
if (value.version !== 1 || !value.userId || !value.appId || !value.installation || !value.coreId)
|
|
39
|
+
throw new Error("Feedback capability response is incompatible.");
|
|
40
|
+
if (!controller.signal.aborted)
|
|
41
|
+
setCapabilities(value);
|
|
42
|
+
}).catch(e => { if (!controller.signal.aborted)
|
|
43
|
+
setUnavailable(errorMessage(e)); });
|
|
44
|
+
return () => controller.abort();
|
|
45
|
+
}, [active, path, retry]);
|
|
46
|
+
if (!active || typeof document === "undefined")
|
|
47
|
+
return null;
|
|
48
|
+
return capabilities ? _jsx(FeedbackEditor, { capabilities: capabilities, path: path }, draftKey(capabilities))
|
|
49
|
+
: unavailable ? createPortal(_jsxs("div", { "data-hosty-feedback": true, "data-hosty-sdk": true, className: "hosty-feedback-launch", children: [_jsx(SdkChromeStyles, {}), _jsx("button", { title: unavailable, onClick: () => setRetry(r => r + 1), children: "Retry feedback" })] }), document.body) : null;
|
|
50
|
+
}
|
|
51
|
+
function FeedbackEditor({ capabilities, path }) {
|
|
52
|
+
const [dictating, setDictating] = useState(false);
|
|
53
|
+
const [reviewTargets, setReviewTargets] = useState([]);
|
|
54
|
+
const key = draftKey(capabilities);
|
|
55
|
+
const [draft, setDraft] = useState(() => blankDraft(key));
|
|
56
|
+
const current = useRef(draft), savedRevision = useRef(0), savedRequestId = useRef(null), queue = useRef(Promise.resolve()), saveFailure = useRef(null);
|
|
57
|
+
const live = useRef(true);
|
|
58
|
+
const [ready, setReady] = useState(false), [open, setOpen] = useState(false), [saving, setSaving] = useState(false);
|
|
59
|
+
const [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
|
|
60
|
+
const [picking, setPicking] = useState(null);
|
|
61
|
+
const [highlight, setHighlight] = useState(null);
|
|
62
|
+
const [edit, setEdit] = useState(null);
|
|
63
|
+
const [hidden, setHidden] = useState(false), [capturing, setCapturing] = useState(false);
|
|
64
|
+
const [preview, setPreview] = useState(null);
|
|
65
|
+
const elements = useRef(new Map());
|
|
66
|
+
const capture = useRef(null);
|
|
67
|
+
const root = useRef(null);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
live.current = true;
|
|
70
|
+
void readDraft(key).then(value => {
|
|
71
|
+
if (!live.current)
|
|
72
|
+
return;
|
|
73
|
+
if (value) {
|
|
74
|
+
current.current = value;
|
|
75
|
+
savedRevision.current = value.revision;
|
|
76
|
+
savedRequestId.current = value.requestId;
|
|
77
|
+
setDraft(value);
|
|
78
|
+
setNotice("Saved draft restored.");
|
|
79
|
+
}
|
|
80
|
+
}).catch(e => { if (live.current)
|
|
81
|
+
setError(errorMessage(e)); }).finally(() => { if (live.current)
|
|
82
|
+
setReady(true); });
|
|
83
|
+
return () => { live.current = false; capture.current?.abort(); };
|
|
84
|
+
}, [key]);
|
|
85
|
+
function update(change) {
|
|
86
|
+
const next = { ...current.current, ...change, revision: current.current.revision + 1 };
|
|
87
|
+
current.current = next;
|
|
88
|
+
setDraft(next);
|
|
89
|
+
setSaving(true);
|
|
90
|
+
setNotice("");
|
|
91
|
+
queue.current = queue.current.then(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await saveDraft(next, savedRevision.current, savedRequestId.current);
|
|
94
|
+
savedRevision.current = next.revision;
|
|
95
|
+
savedRequestId.current = next.requestId;
|
|
96
|
+
saveFailure.current = null;
|
|
97
|
+
if (live.current) {
|
|
98
|
+
setError("");
|
|
99
|
+
setSaving(current.current.revision !== next.revision);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
saveFailure.current = new Error(errorMessage(e));
|
|
104
|
+
if (live.current) {
|
|
105
|
+
setSaving(false);
|
|
106
|
+
setError("Not saved locally: " + errorMessage(e));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function flush() { await queue.current; if (saveFailure.current)
|
|
112
|
+
throw saveFailure.current; }
|
|
113
|
+
function noteChange(id, change) {
|
|
114
|
+
if (current.current.submitting)
|
|
115
|
+
return;
|
|
116
|
+
update({ annotations: current.current.annotations.map(n => n.id === id ? { ...n, ...change } : n) });
|
|
117
|
+
}
|
|
118
|
+
function add(target, element) {
|
|
119
|
+
if (current.current.annotations.length >= capabilities.maxAnnotations) {
|
|
120
|
+
setError("A report can contain at most 20 annotations.");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const note = makeNote(target);
|
|
124
|
+
if (element)
|
|
125
|
+
elements.current.set(note.id, element);
|
|
126
|
+
update({ annotations: [...current.current.annotations, note] });
|
|
127
|
+
setEdit({ id: note.id, where: "anchor" });
|
|
128
|
+
setPicking(null);
|
|
129
|
+
setHighlight(target.bounds ?? null);
|
|
130
|
+
}
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!picking || draft.submitting || !open)
|
|
133
|
+
return;
|
|
134
|
+
let start = null;
|
|
135
|
+
const inside = (event) => event.composedPath().some(node => node instanceof Element && node.hasAttribute("data-hosty-feedback"));
|
|
136
|
+
const move = (event) => {
|
|
137
|
+
if (inside(event))
|
|
138
|
+
return;
|
|
139
|
+
if (picking === "region" && start)
|
|
140
|
+
setHighlight({ x: Math.min(start.x, event.clientX), y: Math.min(start.y, event.clientY),
|
|
141
|
+
width: Math.abs(event.clientX - start.x), height: Math.abs(event.clientY - start.y) });
|
|
142
|
+
else if (picking === "element") {
|
|
143
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
144
|
+
if (element)
|
|
145
|
+
setHighlight(rect(element.getBoundingClientRect()));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const down = (event) => {
|
|
149
|
+
if (inside(event))
|
|
150
|
+
return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
event.stopImmediatePropagation();
|
|
153
|
+
// React may remove the picker effect before the native click follows pointerup.
|
|
154
|
+
const swallow = (click) => { click.preventDefault(); click.stopImmediatePropagation(); cleanup(); };
|
|
155
|
+
const cleanup = () => { document.removeEventListener("click", swallow, true); clearTimeout(timer); };
|
|
156
|
+
const timer = setTimeout(cleanup, 1500);
|
|
157
|
+
document.addEventListener("click", swallow, { capture: true, once: true });
|
|
158
|
+
if (picking === "region") {
|
|
159
|
+
start = { x: event.clientX, y: event.clientY };
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
163
|
+
if (!element)
|
|
164
|
+
return;
|
|
165
|
+
// Never read input values, text content, or arbitrary data attributes into evidence.
|
|
166
|
+
const label = element.getAttribute("aria-label")?.slice(0, 200) || element.tagName.toLowerCase();
|
|
167
|
+
const selector = element.id ? "#" + CSS.escape(element.id) : element.tagName.toLowerCase();
|
|
168
|
+
add({ kind: "element", label, selector, bounds: rect(element.getBoundingClientRect()), viewport: viewport() }, element);
|
|
169
|
+
};
|
|
170
|
+
const up = (event) => {
|
|
171
|
+
if (!start)
|
|
172
|
+
return;
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
event.stopImmediatePropagation();
|
|
175
|
+
const b = { x: Math.min(start.x, event.clientX), y: Math.min(start.y, event.clientY),
|
|
176
|
+
width: Math.abs(event.clientX - start.x), height: Math.abs(event.clientY - start.y) };
|
|
177
|
+
start = null;
|
|
178
|
+
if (b.width > 3 && b.height > 3)
|
|
179
|
+
add({ kind: "region", label: "Selected region", bounds: b, viewport: viewport() });
|
|
180
|
+
};
|
|
181
|
+
const click = (event) => { if (!inside(event)) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopImmediatePropagation();
|
|
184
|
+
} };
|
|
185
|
+
const escape = (event) => { if (event.key === "Escape") {
|
|
186
|
+
setPicking(null);
|
|
187
|
+
setHighlight(null);
|
|
188
|
+
} };
|
|
189
|
+
document.addEventListener("pointermove", move, true);
|
|
190
|
+
document.addEventListener("pointerdown", down, true);
|
|
191
|
+
document.addEventListener("pointerup", up, true);
|
|
192
|
+
document.addEventListener("click", click, true);
|
|
193
|
+
document.addEventListener("keydown", escape, true);
|
|
194
|
+
const old = document.documentElement.style.cursor;
|
|
195
|
+
document.documentElement.style.cursor = "crosshair";
|
|
196
|
+
return () => {
|
|
197
|
+
document.removeEventListener("pointermove", move, true);
|
|
198
|
+
document.removeEventListener("pointerdown", down, true);
|
|
199
|
+
document.removeEventListener("pointerup", up, true);
|
|
200
|
+
document.removeEventListener("click", click, true);
|
|
201
|
+
document.removeEventListener("keydown", escape, true);
|
|
202
|
+
document.documentElement.style.cursor = old;
|
|
203
|
+
};
|
|
204
|
+
}, [picking, draft.submitting, open]);
|
|
205
|
+
const selected = draft.annotations.find(n => n.id === edit?.id);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (picking || !edit || !open)
|
|
208
|
+
return;
|
|
209
|
+
const refresh = () => {
|
|
210
|
+
const element = elements.current.get(edit.id);
|
|
211
|
+
setHighlight(element?.isConnected ? rect(element.getBoundingClientRect()) : current.current.annotations.find(n => n.id === edit.id)?.target?.bounds ?? null);
|
|
212
|
+
};
|
|
213
|
+
refresh();
|
|
214
|
+
window.addEventListener("scroll", refresh, true);
|
|
215
|
+
window.addEventListener("resize", refresh);
|
|
216
|
+
return () => { window.removeEventListener("scroll", refresh, true); window.removeEventListener("resize", refresh); };
|
|
217
|
+
}, [edit, picking, open]);
|
|
218
|
+
async function done() {
|
|
219
|
+
setEdit(null);
|
|
220
|
+
setHighlight(null);
|
|
221
|
+
try {
|
|
222
|
+
await flush();
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
setError(errorMessage(e));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function screenshot(id) {
|
|
229
|
+
if (capture.current || draft.submitting)
|
|
230
|
+
return;
|
|
231
|
+
const operation = new AbortController();
|
|
232
|
+
capture.current = operation;
|
|
233
|
+
setCapturing(true);
|
|
234
|
+
setError("");
|
|
235
|
+
try {
|
|
236
|
+
const blob = await captureStill(value => { if (root.current)
|
|
237
|
+
root.current.style.visibility = value ? "hidden" : ""; setHidden(value); }, operation.signal);
|
|
238
|
+
if (live.current && !operation.signal.aborted)
|
|
239
|
+
setPreview({ id, blob });
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (live.current && !operation.signal.aborted)
|
|
243
|
+
setError(errorMessage(e));
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (capture.current === operation)
|
|
247
|
+
capture.current = null;
|
|
248
|
+
if (live.current)
|
|
249
|
+
setCapturing(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function acceptImage(blob) {
|
|
253
|
+
if (!preview)
|
|
254
|
+
return;
|
|
255
|
+
if (blob.size > capabilities.maxImageBytes) {
|
|
256
|
+
setError("Crop a smaller area: each screenshot must fit within 2 MiB.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const total = current.current.annotations.filter(n => n.id !== preview.id).reduce((sum, n) => sum + (n.image?.size ?? 0), 0) + blob.size;
|
|
260
|
+
if (total > capabilities.maxTotalImageBytes) {
|
|
261
|
+
setError("Screenshots may total at most 8 MiB. Remove or crop an existing image.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
noteChange(preview.id, { image: blob });
|
|
265
|
+
setPreview(null);
|
|
266
|
+
}
|
|
267
|
+
async function send() {
|
|
268
|
+
if (busy || !current.current.annotations.length || current.current.annotations.some(n => !n.text.trim()))
|
|
269
|
+
return;
|
|
270
|
+
setBusy(true);
|
|
271
|
+
setError("");
|
|
272
|
+
try {
|
|
273
|
+
update({ submitting: true });
|
|
274
|
+
await flush();
|
|
275
|
+
const frozen = current.current;
|
|
276
|
+
const response = await appFetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(await submission(frozen)) });
|
|
277
|
+
const result = await response.json().catch(() => null);
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
if ([400, 413, 415].includes(response.status))
|
|
280
|
+
update({ submitting: false, requestId: crypto.randomUUID() });
|
|
281
|
+
throw new Error(result?.message ?? "Submission was not confirmed. Retry the same feedback; your draft is retained.");
|
|
282
|
+
}
|
|
283
|
+
if (!result || !["submitted", "deleted"].includes(result.status ?? ""))
|
|
284
|
+
throw new Error("Submission was not confirmed. Retry the same feedback.");
|
|
285
|
+
await deleteDraft(key, frozen.requestId, frozen.revision);
|
|
286
|
+
const fresh = blankDraft(key);
|
|
287
|
+
savedRevision.current = 0;
|
|
288
|
+
savedRequestId.current = null;
|
|
289
|
+
current.current = fresh;
|
|
290
|
+
setDraft(fresh);
|
|
291
|
+
setReviewTargets([]);
|
|
292
|
+
if (capabilities.administrator && result.status === "submitted" && result.id) {
|
|
293
|
+
void appFetch(path + "/" + encodeURIComponent(result.id) + "/review-targets").then(async (response) => {
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
return;
|
|
296
|
+
const value = await response.json();
|
|
297
|
+
if (live.current)
|
|
298
|
+
setReviewTargets(value.targets ?? []);
|
|
299
|
+
}).catch(() => undefined);
|
|
300
|
+
}
|
|
301
|
+
setEdit(null);
|
|
302
|
+
setHighlight(null);
|
|
303
|
+
setNotice(result.status === "deleted" ? "This feedback was already submitted and subsequently removed with its workspace." : "Feedback submitted.");
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
setError(errorMessage(e));
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
if (live.current)
|
|
310
|
+
setBusy(false);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function discard() {
|
|
314
|
+
if (!window.confirm(current.current.submitting ? "This submission may already exist. Discard its local retry record? This does not delete a submitted feedback." : "Discard this local feedback draft and its screenshots?"))
|
|
315
|
+
return;
|
|
316
|
+
try {
|
|
317
|
+
await queue.current;
|
|
318
|
+
await deleteDraft(key, current.current.requestId, savedRevision.current);
|
|
319
|
+
const fresh = blankDraft(key);
|
|
320
|
+
savedRevision.current = 0;
|
|
321
|
+
savedRequestId.current = null;
|
|
322
|
+
saveFailure.current = null;
|
|
323
|
+
current.current = fresh;
|
|
324
|
+
setDraft(fresh);
|
|
325
|
+
setEdit(null);
|
|
326
|
+
setHighlight(null);
|
|
327
|
+
setError("");
|
|
328
|
+
setNotice("Draft discarded.");
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
setError(errorMessage(e));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function editor(note) {
|
|
335
|
+
return _jsxs(_Fragment, { children: [_jsxs("label", { children: ["Comment", _jsx("textarea", { "aria-label": "Comment", autoFocus: true, maxLength: 2000, value: note.text, disabled: draft.submitting, onChange: event => noteChange(note.id, { text: event.target.value }), placeholder: "What should change here?" })] }), _jsx(FeedbackSpeech, { path: path, disabled: draft.submitting, onBusyChange: setDictating, onText: text => {
|
|
336
|
+
const old = current.current.annotations.find(n => n.id === note.id);
|
|
337
|
+
if (!old)
|
|
338
|
+
return;
|
|
339
|
+
const next = [old.text, text].filter(Boolean).join(" ");
|
|
340
|
+
if (next.length > 2000) {
|
|
341
|
+
setError("Dictation exceeds the comment limit. Shorten the comment before dictating again.");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
noteChange(note.id, { text: next });
|
|
345
|
+
} }), _jsxs("div", { className: "hf-actions", children: [_jsx("button", { onClick: () => void screenshot(note.id), disabled: capturing || draft.submitting, children: "Add screenshot" }), _jsx("button", { disabled: dictating, onClick: () => void done(), children: "Done" })] })] });
|
|
346
|
+
}
|
|
347
|
+
const popup = { left: Math.max(12, Math.min(highlight?.x ?? 24, innerWidth - 340)),
|
|
348
|
+
top: Math.max(12, Math.min((highlight?.y ?? 40) + (highlight?.height ?? 0) + 12, innerHeight - 250)) };
|
|
349
|
+
if (!ready)
|
|
350
|
+
return null;
|
|
351
|
+
return createPortal(_jsxs("div", { ref: root, "data-hosty-feedback": true, "data-hosty-sdk": true, style: { visibility: hidden ? "hidden" : undefined }, children: [_jsx(SdkChromeStyles, {}), _jsx("style", { children: feedbackStyles }), !open && _jsxs("button", { className: "hosty-feedback-launch", onClick: () => setOpen(true), children: ["Annotate", draft.annotations.length ? " · " + draft.annotations.length : ""] }), open && _jsxs(_Fragment, { children: [picking && _jsxs("div", { className: "hosty-feedback-launch", role: "status", children: [picking === "element" ? "Select an element" : "Drag a region", " \u00B7 Escape cancels ", _jsx("button", { onClick: () => { setPicking(null); setHighlight(null); }, children: "Cancel selection" })] }), _jsxs("aside", { className: "hf-panel", "aria-label": "Feedback draft", style: { visibility: picking ? "hidden" : undefined,
|
|
352
|
+
...(edit?.where === "anchor" && highlight && highlight.x > innerWidth - 400 ? { left: 12, right: "auto" } : {}) }, children: [_jsxs("header", { children: [_jsxs("div", { children: [_jsx("strong", { children: "Feedback" }), _jsx("small", { children: saving ? "Saving…" : saveFailure.current ? "Not saved locally" : draft.revision ? "Saved locally" : "New report" })] }), _jsx("button", { "aria-label": "Close feedback", onClick: () => { setOpen(false); setPicking(null); capture.current?.abort(); }, children: "\u00D7" })] }), _jsx("p", { children: "Collect related comments, then send them as one feedback." }), _jsxs("div", { className: "hf-actions", children: [_jsx("button", { disabled: draft.submitting, "aria-pressed": picking === "element", onClick: () => { setEdit(null); setPicking("element"); }, children: "Select element" }), _jsx("button", { disabled: draft.submitting, "aria-pressed": picking === "region", onClick: () => { setEdit(null); setPicking("region"); }, children: "Select region" }), _jsx("button", { disabled: draft.submitting, onClick: () => add({ kind: "page", label: "This page", viewport: viewport() }), children: "Page comment" })] }), picking && _jsxs("p", { role: "status", children: [picking === "element" ? "Click an element to annotate it." : "Drag over the area to annotate.", " Escape cancels selection."] }), _jsx("ol", { children: draft.annotations.map((note, index) => _jsxs("li", { children: [_jsxs("div", { className: "hf-note-heading", children: [_jsxs("button", { onClick: () => { setPicking(null); setEdit({ id: note.id, where: "anchor" }); elements.current.get(note.id)?.scrollIntoView({ block: "nearest" }); }, children: [index + 1, ". ", note.target?.label ?? "Annotation"] }), _jsx("button", { disabled: draft.submitting, onClick: () => { update({ annotations: current.current.annotations.filter(n => n.id !== note.id) }); if (edit?.id === note.id)
|
|
353
|
+
setEdit(null); }, children: "Remove" })] }), edit?.id === note.id && edit.where === "side" ? editor(note) : _jsxs(_Fragment, { children: [_jsx("p", { children: note.text || "Add a comment…" }), _jsx("button", { onClick: () => { setPicking(null); setEdit({ id: note.id, where: "side" }); }, children: "Edit" })] }), note.image && _jsxs(_Fragment, { children: [_jsx(ImagePreview, { blob: note.image }), _jsx("button", { disabled: draft.submitting, onClick: () => noteChange(note.id, { image: undefined }), children: "Remove screenshot" })] })] }, note.id)) }), _jsxs("label", { children: ["Overall comment (optional)", _jsx("textarea", { maxLength: 5000, value: draft.comment, disabled: draft.submitting, onChange: event => update({ comment: event.target.value }) })] }), error && _jsx("p", { role: "alert", className: "hf-error", children: error }), notice && _jsx("p", { role: "status", children: notice }), reviewTargets.map(target => _jsx("p", { children: _jsxs("a", { href: target.href, target: "_blank", rel: "noopener noreferrer", children: ["Send to chat in ", target.label] }) }, target.href)), draft.submitting && !busy && _jsx("p", { children: "The submission may already have reached Core. Retry to recover its confirmation." }), _jsxs("footer", { children: [_jsx("button", { onClick: () => void discard(), disabled: busy, children: "Discard draft" }), _jsx("button", { className: "hf-primary", disabled: busy || dictating || capturing || !draft.annotations.length || draft.annotations.some(n => !n.text.trim()), onClick: () => void send(), children: busy ? "Sending…" : draft.submitting ? "Retry submission" : "Submit feedback" })] })] }), highlight && _jsx("div", { className: "hf-highlight", "aria-hidden": true, style: { left: highlight.x, top: highlight.y, width: highlight.width, height: highlight.height } }), selected && edit?.where === "anchor" && !picking && _jsxs("section", { className: "hf-popover", role: "dialog", "aria-label": "Annotation comment", style: popup, children: [_jsx("strong", { children: selected.target?.label ?? "Annotation" }), editor(selected)] }), preview && _jsx(CropPreview, { blob: preview.blob, onCancel: () => setPreview(null), onAccept: acceptImage })] })] }), document.body);
|
|
354
|
+
}
|
|
355
|
+
function CropPreview({ blob, onCancel, onAccept }) {
|
|
356
|
+
const url = useBlobUrl(blob);
|
|
357
|
+
const [crop, setCrop] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState("");
|
|
358
|
+
const dialog = useRef(null);
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const node = dialog.current;
|
|
361
|
+
node?.showModal();
|
|
362
|
+
return () => { node?.close(); };
|
|
363
|
+
}, []);
|
|
364
|
+
const start = useRef(null);
|
|
365
|
+
const point = (event) => {
|
|
366
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
367
|
+
return { x: Math.max(0, Math.min(1, (event.clientX - bounds.x) / bounds.width)), y: Math.max(0, Math.min(1, (event.clientY - bounds.y) / bounds.height)) };
|
|
368
|
+
};
|
|
369
|
+
return _jsxs("dialog", { ref: dialog, className: "hf-crop", "aria-label": "Review screenshot", onCancel: event => { event.preventDefault(); if (!busy)
|
|
370
|
+
onCancel(); }, children: [_jsx("h2", { children: "Review screenshot" }), _jsx("p", { children: "Check the selected source. Drag to crop, or adjust the percentages below. Only the approved image is included." }), _jsxs("div", { className: "hf-crop-image", onPointerDown: event => { start.current = point(event); event.currentTarget.setPointerCapture(event.pointerId); }, onPointerMove: event => {
|
|
371
|
+
if (!start.current)
|
|
372
|
+
return;
|
|
373
|
+
const p = point(event);
|
|
374
|
+
setCrop({ x: Math.min(p.x, start.current.x), y: Math.min(p.y, start.current.y),
|
|
375
|
+
width: Math.abs(p.x - start.current.x), height: Math.abs(p.y - start.current.y) });
|
|
376
|
+
}, onPointerUp: () => { start.current = null; }, children: [url && _jsx("img", { src: url, alt: "Captured screen for review", draggable: false }), crop && _jsx("div", { className: "hf-crop-selection", style: { left: crop.x * 100 + "%", top: crop.y * 100 + "%", width: crop.width * 100 + "%", height: crop.height * 100 + "%" } })] }), _jsx("div", { className: "hf-actions", children: ["x", "y", "width", "height"].map(field => _jsxs("label", { children: [field === "x" ? "Left %" : field === "y" ? "Top %" : field === "width" ? "Width %" : "Height %", _jsx("input", { type: "number", min: 0, max: 100, value: Math.round((crop?.[field] ?? (field === "width" || field === "height" ? 1 : 0)) * 100), onChange: event => setCrop({ ...(crop ?? { x: 0, y: 0, width: 1, height: 1 }), [field]: Math.max(0, Math.min(1, Number(event.target.value) / 100)) }) })] }, field)) }), error && _jsx("p", { role: "alert", children: error }), _jsxs("footer", { children: [_jsx("button", { onClick: onCancel, disabled: busy, children: "Cancel" }), _jsx("button", { onClick: () => setCrop(null), children: "Use full image" }), _jsx("button", { className: "hf-primary", disabled: busy || !!crop && (!crop.width || !crop.height), onClick: () => { setBusy(true); void cropStill(blob, crop).then(onAccept).catch(e => setError(errorMessage(e))).finally(() => setBusy(false)); }, children: "Include screenshot" })] })] });
|
|
377
|
+
}
|
|
378
|
+
const feedbackStyles = [
|
|
379
|
+
"[data-hosty-feedback]{font-family:var(--hs-font);font-size:14px;color:#0f1b2d;line-height:1.45}",
|
|
380
|
+
".hosty-feedback-launch{position:fixed;left:16px;bottom:16px;z-index:2147481000;background:#fff;border:1px solid #bdc8d6;border-radius:20px;padding:10px 16px;box-shadow:0 3px 14px #0f1b2d20}",
|
|
381
|
+
"[data-hosty-feedback] button{font:inherit;cursor:pointer;border:1px solid #cbd5e1;border-radius:7px;background:#fff;padding:6px 10px;color:#0f1b2d}",
|
|
382
|
+
"[data-hosty-feedback] button:disabled{opacity:.5;cursor:not-allowed}[data-hosty-feedback] button:focus-visible,[data-hosty-feedback] textarea:focus{outline:2px solid #65a30d;outline-offset:2px}",
|
|
383
|
+
"[data-hosty-feedback] textarea{box-sizing:border-box;display:block;width:100%;min-height:90px;font:inherit;color:#0f1b2d;background:#fff;border:1px solid #cbd5e1;border-radius:7px;padding:8px;resize:vertical;margin:6px 0}",
|
|
384
|
+
"[data-hosty-feedback] .hf-primary{background:#a3e635;border-color:#84cc16}[data-hosty-feedback] .hf-error{color:#b91c1c}",
|
|
385
|
+
".hf-panel{box-sizing:border-box;position:fixed;right:12px;top:12px;bottom:12px;width:min(360px,calc(100vw - 24px));background:#fff;border:1px solid #cbd5e1;border-radius:12px;padding:16px;box-shadow:0 10px 45px #0f1b2d30;overflow:auto;z-index:2147481001}",
|
|
386
|
+
".hf-panel header,.hf-note-heading,.hf-panel footer,.hf-crop footer{display:flex;justify-content:space-between;align-items:center;gap:8px}.hf-panel small{display:block;color:#64748b}.hf-panel ol{padding:0;list-style:none}.hf-panel li{border:1px solid #e2e8f0;border-radius:9px;padding:10px;margin:12px 0}.hf-panel p{white-space:pre-wrap;overflow-wrap:anywhere}.hf-panel img{max-width:100%;border-radius:6px;margin:8px 0}.hf-panel footer{margin-top:14px}",
|
|
387
|
+
".hf-actions{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}.hf-highlight{position:fixed;border:2px solid #65a30d;box-shadow:0 0 0 2px #ecfccb;pointer-events:none;z-index:2147481000;background:#a3e63518;box-sizing:border-box}",
|
|
388
|
+
".hf-popover>strong{display:block;margin-bottom:6px}.hf-popover{position:fixed;width:min(310px,calc(100vw - 32px));box-sizing:border-box;z-index:2147481002;background:#fff;border:1px solid #84cc16;border-radius:10px;padding:12px;box-shadow:0 8px 28px #0f1b2d35}",
|
|
389
|
+
".hf-backdrop{position:fixed;inset:0;background:#0f1b2d88;display:grid;place-items:center;z-index:2147481003;padding:16px}.hf-crop::backdrop{background:#0f1b2d88}.hf-crop{border:0;color:#0f1b2d;background:#fff;border-radius:12px;padding:20px;width:min(760px,90vw);max-height:90vh;overflow:auto}.hf-crop-image{position:relative;display:table;max-width:100%;touch-action:none}.hf-crop-image img{display:block;max-width:100%;max-height:55vh;user-select:none}.hf-crop-selection{position:absolute;border:2px solid #65a30d;background:#a3e63530;pointer-events:none;box-sizing:border-box}.hf-crop input{display:block;width:70px}"
|
|
390
|
+
].join("\n");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
|
|
2
|
+
import { type FeedbackCapabilities, type FeedbackSubmit, type FeedbackReport, type FeedbackCollection, type FeedbackDispatch, type FeedbackDispatchInput, type FeedbackDelivery, type FeedbackOutcome } from "@hosty-sdk/app/feedback";
|
|
3
|
+
export declare class FeedbackServerClient {
|
|
4
|
+
private readonly appId;
|
|
5
|
+
private readonly core;
|
|
6
|
+
private readonly service;
|
|
7
|
+
constructor(options?: ProviderServerOptions);
|
|
8
|
+
private response;
|
|
9
|
+
private request;
|
|
10
|
+
capabilities(token: string, signal?: AbortSignal): Promise<FeedbackCapabilities>;
|
|
11
|
+
submit(input: FeedbackSubmit, token: string, signal?: AbortSignal): Promise<{
|
|
12
|
+
id: string;
|
|
13
|
+
status: string;
|
|
14
|
+
}>;
|
|
15
|
+
reviewTargets(id: string, token: string, signal?: AbortSignal): Promise<{
|
|
16
|
+
targets: {
|
|
17
|
+
label: string;
|
|
18
|
+
href: string;
|
|
19
|
+
}[];
|
|
20
|
+
}>;
|
|
21
|
+
list(token: string, signal?: AbortSignal): Promise<FeedbackCollection>;
|
|
22
|
+
change(id: string, input: {
|
|
23
|
+
expectedRevision: number;
|
|
24
|
+
workspaceId: string | null;
|
|
25
|
+
disposition?: string | null;
|
|
26
|
+
}, token: string): Promise<FeedbackReport>;
|
|
27
|
+
reserve(input: FeedbackDispatchInput, token: string): Promise<FeedbackDispatch>;
|
|
28
|
+
delivery(id: string, input: FeedbackDelivery, token: string): Promise<FeedbackDispatch>;
|
|
29
|
+
outcomes(id: string, outcomes: FeedbackOutcome[], token: string): Promise<FeedbackDispatch>;
|
|
30
|
+
image(id: string, noteId: string, token: string, signal?: AbortSignal): Promise<Response>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { FeedbackError } from "@hosty-sdk/app/feedback";
|
|
2
|
+
export class FeedbackServerClient {
|
|
3
|
+
appId;
|
|
4
|
+
core;
|
|
5
|
+
service;
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
|
|
8
|
+
this.core = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
|
|
9
|
+
this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
|
|
10
|
+
if (!this.appId || !this.service)
|
|
11
|
+
throw new FeedbackError("hosty_misconfigured", "Hosty app credentials are unavailable.", 503);
|
|
12
|
+
}
|
|
13
|
+
async response(path, token, body, signal) {
|
|
14
|
+
if (!token)
|
|
15
|
+
throw new FeedbackError("identity_required", "Sign in to use feedback.", 401);
|
|
16
|
+
const response = await fetch(new URL("/api/internal/apps/" + encodeURIComponent(this.appId) + "/" + path, this.core), {
|
|
17
|
+
method: body === undefined ? "GET" : "POST", redirect: "error", cache: "no-store",
|
|
18
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
19
|
+
headers: { authorization: "Bearer " + this.service, "X-Hosty-User-Token": token, "content-type": "application/json" },
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const error = await response.json().catch(() => null);
|
|
24
|
+
throw new FeedbackError(error?.code ?? "feedback_unavailable", error?.message ?? "Feedback is unavailable. Your draft is retained.", response.status);
|
|
25
|
+
}
|
|
26
|
+
return response;
|
|
27
|
+
}
|
|
28
|
+
async request(path, token, body, signal) {
|
|
29
|
+
return (await this.response(path, token, body, signal)).json();
|
|
30
|
+
}
|
|
31
|
+
capabilities(token, signal) { return this.request("ui/feedback", token, undefined, signal); }
|
|
32
|
+
submit(input, token, signal) { return this.request("ui/feedback", token, input, signal); }
|
|
33
|
+
reviewTargets(id, token, signal) { return this.request("ui/feedback/" + encodeURIComponent(id) + "/review-targets", token, undefined, signal); }
|
|
34
|
+
list(token, signal) { return this.request("feedback", token, undefined, signal); }
|
|
35
|
+
change(id, input, token) {
|
|
36
|
+
return this.request("feedback/" + encodeURIComponent(id), token, input);
|
|
37
|
+
}
|
|
38
|
+
reserve(input, token) { return this.request("feedback/dispatches", token, input); }
|
|
39
|
+
delivery(id, input, token) {
|
|
40
|
+
return this.request("feedback/dispatches/" + encodeURIComponent(id) + "/delivery", token, input);
|
|
41
|
+
}
|
|
42
|
+
outcomes(id, outcomes, token) {
|
|
43
|
+
return this.request("feedback/dispatches/" + encodeURIComponent(id) + "/outcomes", token, { outcomes });
|
|
44
|
+
}
|
|
45
|
+
image(id, noteId, token, signal) {
|
|
46
|
+
return this.response("feedback/" + encodeURIComponent(id) + "/images/" + encodeURIComponent(noteId), token, undefined, signal);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { appFetch } from "./browser-auth.js";
|
|
5
|
+
import { recordingWave } from "./feedback-audio.js";
|
|
6
|
+
const dispose = (op) => { if (!op)
|
|
7
|
+
return; op.controller.abort(); clearTimeout(op.timer); if (op.recorder?.state === "recording")
|
|
8
|
+
op.recorder.stop(); op.stream?.getTracks().forEach(t => t.stop()); };
|
|
9
|
+
export function FeedbackSpeech({ path, disabled, onText, onBusyChange }) {
|
|
10
|
+
const [providers, setProviders] = useState([]), [choice, setChoice] = useState("");
|
|
11
|
+
const [phase, setPhase] = useState("idle"), [error, setError] = useState("");
|
|
12
|
+
const active = useRef(null), result = useRef(onText);
|
|
13
|
+
result.current = onText;
|
|
14
|
+
const endpoint = path + "/speech";
|
|
15
|
+
useEffect(() => { onBusyChange(phase !== "idle"); return () => onBusyChange(false); }, [phase, onBusyChange]);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const abort = new AbortController();
|
|
18
|
+
void appFetch(endpoint, { credentials: "include", signal: abort.signal }).then(async (response) => {
|
|
19
|
+
const value = await response.json();
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new Error(value.message ?? "Speech input is unavailable.");
|
|
22
|
+
if (!abort.signal.aborted) {
|
|
23
|
+
setProviders(value.providers ?? []);
|
|
24
|
+
if (!value.granted)
|
|
25
|
+
setError("Speech input is not enabled for this application.");
|
|
26
|
+
}
|
|
27
|
+
}).catch(e => { if (!abort.signal.aborted)
|
|
28
|
+
setError(e.message); });
|
|
29
|
+
return () => { abort.abort(); dispose(active.current); active.current = null; };
|
|
30
|
+
}, [endpoint]);
|
|
31
|
+
useEffect(() => { if (disabled) {
|
|
32
|
+
dispose(active.current);
|
|
33
|
+
active.current = null;
|
|
34
|
+
setPhase("idle");
|
|
35
|
+
} }, [disabled]);
|
|
36
|
+
const selected = providers.find(p => JSON.stringify([p.appId, p.key]) === choice) ?? (providers.length === 1 ? providers[0] : undefined);
|
|
37
|
+
function cancel() { dispose(active.current); active.current = null; setPhase("idle"); }
|
|
38
|
+
async function start() {
|
|
39
|
+
if (!selected?.available || disabled || active.current)
|
|
40
|
+
return;
|
|
41
|
+
const op = { controller: new AbortController() };
|
|
42
|
+
active.current = op;
|
|
43
|
+
const valid = () => active.current === op && !op.controller.signal.aborted;
|
|
44
|
+
setPhase("starting");
|
|
45
|
+
setError("");
|
|
46
|
+
try {
|
|
47
|
+
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined")
|
|
48
|
+
throw new Error("This browser cannot record audio here. Type your comment instead.");
|
|
49
|
+
op.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
50
|
+
if (!valid()) {
|
|
51
|
+
dispose(op);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const recorder = new MediaRecorder(op.stream);
|
|
55
|
+
op.recorder = recorder;
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
recorder.ondataavailable = e => { bytes += e.data.size; if (bytes > 16 * 1024 * 1024) {
|
|
59
|
+
cancel();
|
|
60
|
+
setError("Recording is too large. Try a shorter comment.");
|
|
61
|
+
}
|
|
62
|
+
else if (e.data.size)
|
|
63
|
+
chunks.push(e.data); };
|
|
64
|
+
recorder.onerror = () => { if (valid()) {
|
|
65
|
+
cancel();
|
|
66
|
+
setError("Recording failed. Type your comment or try again.");
|
|
67
|
+
} };
|
|
68
|
+
recorder.onstop = () => {
|
|
69
|
+
clearTimeout(op.timer);
|
|
70
|
+
op.stream?.getTracks().forEach(t => t.stop());
|
|
71
|
+
if (!valid())
|
|
72
|
+
return;
|
|
73
|
+
setPhase("transcribing");
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const wave = await recordingWave(new Blob(chunks, { type: recorder.mimeType }));
|
|
77
|
+
if (!valid())
|
|
78
|
+
return;
|
|
79
|
+
const data = new Uint8Array(await wave.arrayBuffer());
|
|
80
|
+
let binary = "";
|
|
81
|
+
for (let at = 0; at < data.length; at += 8192)
|
|
82
|
+
binary += String.fromCharCode(...data.subarray(at, at + 8192));
|
|
83
|
+
const response = await appFetch(endpoint, { method: "POST", credentials: "include", signal: op.controller.signal,
|
|
84
|
+
headers: { "content-type": "application/json" }, body: JSON.stringify({ appId: selected.appId, key: selected.key, audio: btoa(binary) }) });
|
|
85
|
+
const value = await response.json();
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new Error(value.message ?? "Speech recognition failed.");
|
|
88
|
+
if (valid()) {
|
|
89
|
+
if (typeof value.text === "string" && value.text.trim())
|
|
90
|
+
result.current(value.text.trim());
|
|
91
|
+
else
|
|
92
|
+
setError("No speech detected.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (valid())
|
|
97
|
+
setError(e instanceof Error ? e.message : "Speech recognition failed.");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (active.current === op)
|
|
101
|
+
cancel();
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
};
|
|
105
|
+
recorder.start(1000);
|
|
106
|
+
setPhase("recording");
|
|
107
|
+
op.timer = setTimeout(() => { if (recorder.state === "recording")
|
|
108
|
+
recorder.stop(); }, 60_000);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (valid()) {
|
|
112
|
+
cancel();
|
|
113
|
+
setError(e instanceof Error ? e.message : "Microphone access failed.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return _jsxs("div", { className: "hf-speech", children: [providers.length > 1 && _jsxs("label", { children: ["Speech provider", _jsxs("select", { value: choice, disabled: phase !== "idle" || disabled, onChange: e => setChoice(e.target.value), children: [_jsx("option", { value: "", children: "Choose provider" }), providers.map(p => _jsx("option", { value: JSON.stringify([p.appId, p.key]), disabled: !p.available, children: p.displayName }, JSON.stringify([p.appId, p.key])))] })] }), phase === "idle" ? _jsx("button", { disabled: disabled || !selected?.available, title: error || "Dictate up to one minute", onClick: () => void start(), children: "Dictate" })
|
|
118
|
+
: _jsxs(_Fragment, { children: [_jsx("button", { disabled: phase !== "recording", onClick: () => active.current?.recorder?.stop(), children: phase === "recording" ? "Stop and transcribe" : phase === "starting" ? "Opening microphone…" : "Transcribing…" }), _jsx("button", { onClick: cancel, children: "Cancel dictation" })] }), error && _jsx("small", { role: "status", children: error })] });
|
|
119
|
+
}
|