@mhosaic/feedback 0.13.0 → 0.15.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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  scrubCredentials
3
- } from "./chunk-DKE6ELJ4.mjs";
3
+ } from "./chunk-FGA63IEZ.mjs";
4
4
 
5
5
  // src/api/client.ts
6
6
  var SCALAR_FIELDS = [
@@ -12,6 +12,23 @@ var SCALAR_FIELDS = [
12
12
  "user_agent",
13
13
  "capture_method"
14
14
  ];
15
+ function assertSafeEndpoint(endpoint) {
16
+ let parsed;
17
+ try {
18
+ parsed = new URL(endpoint);
19
+ } catch {
20
+ throw new Error(
21
+ `[mhosaic-feedback] \`endpoint\` is not a valid URL: ${endpoint}`
22
+ );
23
+ }
24
+ if (parsed.protocol === "https:") return;
25
+ if (parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]")) {
26
+ return;
27
+ }
28
+ throw new Error(
29
+ `[mhosaic-feedback] \`endpoint\` must use https:// (got ${parsed.protocol}//${parsed.hostname}). http:// is only allowed for localhost in dev.`
30
+ );
31
+ }
15
32
  function createApiClient(options) {
16
33
  const endpoint = (options.endpoint ?? "").replace(/\/+$/, "");
17
34
  if (!endpoint) {
@@ -19,6 +36,7 @@ function createApiClient(options) {
19
36
  '[mhosaic-feedback] `endpoint` is required (e.g. "https://feedback.example.com").'
20
37
  );
21
38
  }
39
+ assertSafeEndpoint(endpoint);
22
40
  const fetcher = options.fetch ?? globalThis.fetch;
23
41
  async function submitReport(input) {
24
42
  let payload = input;
@@ -551,10 +569,6 @@ var DEFAULT_STRINGS = {
551
569
  "form.screenshot.annotate": "Annotate",
552
570
  "form.screenshot.error_type": "Unsupported file type. Use PNG, JPEG or WebP.",
553
571
  "form.screenshot.error_size": "File too large (max {max} MB).",
554
- "form.screenshot.or": "or",
555
- "form.screenshot.capture_page": "Capture this page",
556
- "form.screenshot.capture_page_hint": "Pick a tab or window to share",
557
- "form.screenshot.capture_error": "Couldn\u2019t capture the page. Try uploading a screenshot instead.",
558
572
  "form.context.label": "Page",
559
573
  "type.bug": "Bug",
560
574
  "type.feature": "Feature request",
@@ -688,10 +702,6 @@ var FRENCH_STRINGS = {
688
702
  "form.screenshot.annotate": "Annoter",
689
703
  "form.screenshot.error_type": "Format non support\xE9. Utilisez PNG, JPEG ou WebP.",
690
704
  "form.screenshot.error_size": "Fichier trop volumineux (max {max} Mo).",
691
- "form.screenshot.or": "ou",
692
- "form.screenshot.capture_page": "Capturer la page",
693
- "form.screenshot.capture_page_hint": "Choisissez un onglet ou une fen\xEAtre \xE0 partager",
694
- "form.screenshot.capture_error": "Impossible de capturer la page. T\xE9l\xE9versez une capture \xE0 la place.",
695
705
  "form.context.label": "Page",
696
706
  "type.bug": "Bogue",
697
707
  "type.feature": "Suggestion",
@@ -880,64 +890,19 @@ function truncateUrl(url, maxLength = 80) {
880
890
  const keepEnd = maxLength - 1 - keepStart;
881
891
  return `${url.slice(0, keepStart)}\u2026${url.slice(url.length - keepEnd)}`;
882
892
  }
883
- async function captureWithDisplayMedia() {
884
- if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
885
- return null;
886
- }
887
- let stream = null;
893
+ function safeExternalHref(url) {
894
+ if (!url) return void 0;
895
+ let parsed;
888
896
  try {
889
- stream = await navigator.mediaDevices.getDisplayMedia({
890
- video: { displaySurface: "browser" },
891
- audio: false
892
- // Hint that audio isn't needed; not all browsers honor this but it
893
- // avoids the audio-share toggle being defaulted on in some.
894
- });
897
+ parsed = new URL(url);
895
898
  } catch {
896
- return null;
899
+ return void 0;
897
900
  }
898
- try {
899
- const track = stream.getVideoTracks()[0];
900
- if (!track) return null;
901
- const ImageCaptureCtor = window.ImageCapture;
902
- let bitmap = null;
903
- if (ImageCaptureCtor) {
904
- try {
905
- const ic = new ImageCaptureCtor(track);
906
- bitmap = await ic.grabFrame();
907
- } catch {
908
- bitmap = null;
909
- }
910
- }
911
- if (!bitmap) {
912
- bitmap = await grabFrameViaVideo(stream);
913
- }
914
- if (!bitmap) return null;
915
- const canvas = document.createElement("canvas");
916
- canvas.width = bitmap.width;
917
- canvas.height = bitmap.height;
918
- const ctx = canvas.getContext("2d");
919
- if (!ctx) return null;
920
- ctx.drawImage(bitmap, 0, 0);
921
- return await new Promise(
922
- (resolve) => canvas.toBlob(resolve, "image/png")
923
- );
924
- } finally {
925
- stream.getTracks().forEach((t) => t.stop());
926
- }
927
- }
928
- async function grabFrameViaVideo(stream) {
929
- const video = document.createElement("video");
930
- video.muted = true;
931
- video.playsInline = true;
932
- video.srcObject = stream;
933
- try {
934
- await video.play();
935
- await new Promise((r) => requestAnimationFrame(() => r()));
936
- if (!video.videoWidth || !video.videoHeight) return null;
937
- return await createImageBitmap(video);
938
- } catch {
939
- return null;
901
+ if (parsed.protocol === "https:") return parsed.toString();
902
+ if (parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]")) {
903
+ return parsed.toString();
940
904
  }
905
+ return void 0;
941
906
  }
942
907
 
943
908
  // src/widget/ReportDetailView.tsx
@@ -1047,16 +1012,19 @@ function ReportDetailView({
1047
1012
  /* @__PURE__ */ jsxs2("div", { class: "report-detail-body", children: [
1048
1013
  /* @__PURE__ */ jsx2(ContextBlock, { detail, strings }),
1049
1014
  /* @__PURE__ */ jsx2("p", { class: "report-detail-description", children: detail.description }),
1050
- detail.screenshot_url && /* @__PURE__ */ jsx2(
1051
- "a",
1052
- {
1053
- class: "report-detail-screenshot",
1054
- href: detail.screenshot_url,
1055
- target: "_blank",
1056
- rel: "noopener noreferrer",
1057
- children: /* @__PURE__ */ jsx2("img", { src: detail.screenshot_url, alt: "", loading: "lazy" })
1058
- }
1059
- ),
1015
+ detail.screenshot_url && (() => {
1016
+ const safeHref = safeExternalHref(detail.screenshot_url);
1017
+ return safeHref ? /* @__PURE__ */ jsx2(
1018
+ "a",
1019
+ {
1020
+ class: "report-detail-screenshot",
1021
+ href: safeHref,
1022
+ target: "_blank",
1023
+ rel: "noopener noreferrer",
1024
+ children: /* @__PURE__ */ jsx2("img", { src: detail.screenshot_url, alt: "", loading: "lazy" })
1025
+ }
1026
+ ) : /* @__PURE__ */ jsx2("div", { class: "report-detail-screenshot", children: /* @__PURE__ */ jsx2("img", { src: detail.screenshot_url, alt: "", loading: "lazy" }) });
1027
+ })(),
1060
1028
  /* @__PURE__ */ jsx2("h3", { class: "report-detail-section", children: strings["detail.thread"] }),
1061
1029
  detail.comments.length === 0 ? /* @__PURE__ */ jsx2("p", { class: "report-detail-empty", children: strings["detail.no_replies"] }) : /* @__PURE__ */ jsx2("ul", { class: "report-comments", children: detail.comments.map((c) => /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsx2(CommentBubble, { comment: c, strings }) })) }),
1062
1030
  detail.status_history && detail.status_history.length > 0 && /* @__PURE__ */ jsx2(StatusHistorySection, { rows: detail.status_history, strings }),
@@ -1115,19 +1083,22 @@ function ContextBlock({ detail, strings }) {
1115
1083
  /* @__PURE__ */ jsx2("span", { class: "report-detail-context-label", children: strings["detail.context.submitted_at"] }),
1116
1084
  /* @__PURE__ */ jsx2("span", { children: formatSubmittedAt(detail.created_at) })
1117
1085
  ] }),
1118
- detail.page_url && /* @__PURE__ */ jsxs2("div", { class: "report-detail-context-line", title: detail.page_url, children: [
1119
- /* @__PURE__ */ jsx2("span", { class: "report-detail-context-label", children: strings["detail.context.page"] }),
1120
- /* @__PURE__ */ jsx2(
1121
- "a",
1122
- {
1123
- class: "report-detail-context-url",
1124
- href: detail.page_url,
1125
- target: "_blank",
1126
- rel: "noopener noreferrer",
1127
- children: truncateUrl(detail.page_url, 64)
1128
- }
1129
- )
1130
- ] })
1086
+ detail.page_url && (() => {
1087
+ const safeHref = safeExternalHref(detail.page_url);
1088
+ return /* @__PURE__ */ jsxs2("div", { class: "report-detail-context-line", title: detail.page_url, children: [
1089
+ /* @__PURE__ */ jsx2("span", { class: "report-detail-context-label", children: strings["detail.context.page"] }),
1090
+ safeHref ? /* @__PURE__ */ jsx2(
1091
+ "a",
1092
+ {
1093
+ class: "report-detail-context-url",
1094
+ href: safeHref,
1095
+ target: "_blank",
1096
+ rel: "noopener noreferrer",
1097
+ children: truncateUrl(detail.page_url, 64)
1098
+ }
1099
+ ) : /* @__PURE__ */ jsx2("span", { class: "report-detail-context-url", children: truncateUrl(detail.page_url, 64) })
1100
+ ] });
1101
+ })()
1131
1102
  ] });
1132
1103
  }
1133
1104
  function formatSubmittedAt(iso) {
@@ -2347,10 +2318,8 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
2347
2318
  const [localError, setLocalError] = useState5("");
2348
2319
  const [screenshotBlob, setScreenshotBlob] = useState5(null);
2349
2320
  const [screenshotPreview, setScreenshotPreview] = useState5(null);
2350
- const [screenshotMethod, setScreenshotMethod] = useState5("manual");
2351
2321
  const [isDragOver, setIsDragOver] = useState5(false);
2352
2322
  const [annotatorOpen, setAnnotatorOpen] = useState5(false);
2353
- const [capturing, setCapturing] = useState5(false);
2354
2323
  const fileInputRef = useRef4(null);
2355
2324
  const dropZoneRef = useRef4(null);
2356
2325
  const submitting = status === "submitting";
@@ -2361,7 +2330,7 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
2361
2330
  if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
2362
2331
  };
2363
2332
  }, [screenshotPreview]);
2364
- const acceptFile = (file, method = "manual") => {
2333
+ const acceptFile = (file) => {
2365
2334
  setLocalError("");
2366
2335
  if (file instanceof File) {
2367
2336
  const err = validateScreenshotFile(file);
@@ -2375,41 +2344,14 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
2375
2344
  if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
2376
2345
  setScreenshotBlob(file);
2377
2346
  setScreenshotPreview(URL.createObjectURL(file));
2378
- setScreenshotMethod(method);
2379
2347
  };
2380
2348
  const clearScreenshot = () => {
2381
2349
  if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
2382
2350
  setScreenshotPreview(null);
2383
2351
  setScreenshotBlob(null);
2384
- setScreenshotMethod("manual");
2385
2352
  setLocalError("");
2386
2353
  if (fileInputRef.current) fileInputRef.current.value = "";
2387
2354
  };
2388
- const handleCapturePage = async () => {
2389
- if (capturing) return;
2390
- setLocalError("");
2391
- setCapturing(true);
2392
- try {
2393
- const blob = await captureWithDisplayMedia();
2394
- if (!blob) {
2395
- if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
2396
- setLocalError(strings["form.screenshot.capture_error"]);
2397
- }
2398
- return;
2399
- }
2400
- if (blob.size > 10 * 1024 * 1024) {
2401
- setLocalError(
2402
- strings["form.screenshot.error_size"].replace("{max}", "10")
2403
- );
2404
- return;
2405
- }
2406
- acceptFile(blob, "display_media");
2407
- } catch {
2408
- setLocalError(strings["form.screenshot.capture_error"]);
2409
- } finally {
2410
- setCapturing(false);
2411
- }
2412
- };
2413
2355
  const handleFileInputChange = (e) => {
2414
2356
  const file = e.target.files?.[0];
2415
2357
  if (file) acceptFile(file);
@@ -2471,7 +2413,7 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
2471
2413
  };
2472
2414
  if (screenshotBlob) {
2473
2415
  values.screenshot = screenshotBlob;
2474
- values.capture_method = screenshotMethod;
2416
+ values.capture_method = "manual";
2475
2417
  }
2476
2418
  onSubmit(values);
2477
2419
  };
@@ -2584,43 +2526,7 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
2584
2526
  /* @__PURE__ */ jsx8("div", { class: "screenshot-formats", children: strings["form.screenshot.formats"] })
2585
2527
  ]
2586
2528
  }
2587
- ),
2588
- !screenshotPreview && /* @__PURE__ */ jsxs7("div", { class: "screenshot-alt", children: [
2589
- /* @__PURE__ */ jsx8("span", { class: "screenshot-or", children: strings["form.screenshot.or"] }),
2590
- /* @__PURE__ */ jsxs7(
2591
- "button",
2592
- {
2593
- type: "button",
2594
- class: "btn btn--ghost screenshot-capture-page",
2595
- onClick: handleCapturePage,
2596
- disabled: capturing || submitting,
2597
- "aria-label": strings["form.screenshot.capture_page"],
2598
- children: [
2599
- /* @__PURE__ */ jsxs7(
2600
- "svg",
2601
- {
2602
- width: "14",
2603
- height: "14",
2604
- viewBox: "0 0 24 24",
2605
- fill: "none",
2606
- stroke: "currentColor",
2607
- "stroke-width": "2",
2608
- "stroke-linecap": "round",
2609
- "stroke-linejoin": "round",
2610
- "aria-hidden": "true",
2611
- children: [
2612
- /* @__PURE__ */ jsx8("rect", { x: "2", y: "3", width: "20", height: "14", rx: "2", ry: "2" }),
2613
- /* @__PURE__ */ jsx8("line", { x1: "8", y1: "21", x2: "16", y2: "21" }),
2614
- /* @__PURE__ */ jsx8("line", { x1: "12", y1: "17", x2: "12", y2: "21" })
2615
- ]
2616
- }
2617
- ),
2618
- capturing ? strings["form.submitting"] : strings["form.screenshot.capture_page"]
2619
- ]
2620
- }
2621
- ),
2622
- /* @__PURE__ */ jsx8("span", { class: "screenshot-capture-hint", children: strings["form.screenshot.capture_page_hint"] })
2623
- ] })
2529
+ )
2624
2530
  ] }),
2625
2531
  pageUrl && /* @__PURE__ */ jsxs7("div", { class: "page-context", title: pageUrl, children: [
2626
2532
  /* @__PURE__ */ jsx8("span", { class: "page-context-label", children: strings["form.context.label"] }),
@@ -3261,40 +3167,6 @@ var WIDGET_STYLES = `
3261
3167
  .screenshot-cta strong { color: var(--mfb-accent); font-weight: 600; }
3262
3168
  .screenshot-formats { font-size: var(--mfb-text-xs); color: var(--mfb-text-muted); }
3263
3169
 
3264
- /* "or \u2014 Capture this page" row sits below the dropzone. The thin "or"
3265
- * separator borrows the pattern from auth forms so the second option
3266
- * reads as an alternative, not a follow-up step. v0.12. */
3267
- .screenshot-alt {
3268
- display: flex;
3269
- align-items: center;
3270
- gap: 12px;
3271
- margin-top: 8px;
3272
- flex-wrap: wrap;
3273
- }
3274
- .screenshot-or {
3275
- position: relative;
3276
- font-size: var(--mfb-text-xs);
3277
- color: var(--mfb-text-muted);
3278
- text-transform: uppercase;
3279
- letter-spacing: 0.08em;
3280
- padding: 0 4px;
3281
- }
3282
- .screenshot-capture-page {
3283
- display: inline-flex;
3284
- align-items: center;
3285
- gap: 6px;
3286
- }
3287
- .screenshot-capture-page:disabled {
3288
- opacity: 0.55;
3289
- cursor: progress;
3290
- }
3291
- .screenshot-capture-hint {
3292
- font-size: var(--mfb-text-xs);
3293
- color: var(--mfb-text-muted);
3294
- flex: 1 1 100%;
3295
- margin-top: 4px;
3296
- }
3297
-
3298
3170
  .screenshot-preview {
3299
3171
  position: relative;
3300
3172
  border: 1px solid var(--mfb-border);
@@ -4530,6 +4402,7 @@ function mountWidget(options) {
4530
4402
  const mountPoint = document.createElement("div");
4531
4403
  shadow.appendChild(mountPoint);
4532
4404
  let currentState = { open: false, status: "idle", tab: "send" };
4405
+ let postSubmitTimer = null;
4533
4406
  function rerender(state) {
4534
4407
  currentState = state;
4535
4408
  render(h(Root, { state }), mountPoint);
@@ -4545,8 +4418,10 @@ function mountWidget(options) {
4545
4418
  try {
4546
4419
  await options.onSubmit(values);
4547
4420
  rerender({ ...currentState, status: "success" });
4548
- setTimeout(
4549
- () => rerender({
4421
+ if (postSubmitTimer !== null) clearTimeout(postSubmitTimer);
4422
+ postSubmitTimer = setTimeout(() => {
4423
+ postSubmitTimer = null;
4424
+ rerender({
4550
4425
  ...currentState,
4551
4426
  open: false,
4552
4427
  status: "idle",
@@ -4554,9 +4429,8 @@ function mountWidget(options) {
4554
4429
  // they immediately see their just-filed report in the list
4555
4430
  // (and the conversation that's about to start).
4556
4431
  tab: options.api ? "mine" : "send"
4557
- }),
4558
- 1200
4559
- );
4432
+ });
4433
+ }, 1200);
4560
4434
  } catch (err) {
4561
4435
  rerender({
4562
4436
  ...currentState,
@@ -4691,6 +4565,10 @@ function mountWidget(options) {
4691
4565
  rerender({ ...currentState, open: false, status: "idle" });
4692
4566
  },
4693
4567
  dispose() {
4568
+ if (postSubmitTimer !== null) {
4569
+ clearTimeout(postSubmitTimer);
4570
+ postSubmitTimer = null;
4571
+ }
4694
4572
  render(null, mountPoint);
4695
4573
  options.host.innerHTML = "";
4696
4574
  },
@@ -4743,7 +4621,10 @@ function createFeedback(config) {
4743
4621
  async function buildAndSubmit(values) {
4744
4622
  const manualScreenshot = values.synthetic ? void 0 : values.screenshot;
4745
4623
  const technical_context = capture.snapshot();
4746
- if (user) technical_context.user = user;
4624
+ if (user) {
4625
+ const { userHash: _hash, exp: _exp, ...safeUser } = user;
4626
+ technical_context.user = safeUser;
4627
+ }
4747
4628
  if (metadata && Object.keys(metadata).length > 0) {
4748
4629
  technical_context.metadata = { ...metadata };
4749
4630
  }
@@ -4758,8 +4639,8 @@ function createFeedback(config) {
4758
4639
  capture_method: captureMethod,
4759
4640
  technical_context
4760
4641
  };
4761
- if ("0.13.0") {
4762
- payload.widget_version = "0.13.0";
4642
+ if ("0.15.0") {
4643
+ payload.widget_version = "0.15.0";
4763
4644
  }
4764
4645
  if (manualScreenshot) payload.screenshot = manualScreenshot;
4765
4646
  if (values.synthetic) payload.synthetic = true;
@@ -4845,4 +4726,4 @@ function createFeedback(config) {
4845
4726
  export {
4846
4727
  createFeedback
4847
4728
  };
4848
- //# sourceMappingURL=chunk-64HXWJCH.mjs.map
4729
+ //# sourceMappingURL=chunk-67JM6XVT.mjs.map