@k2b/ui 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.
Files changed (74) hide show
  1. package/dist/browser/content/FileGrid.js +28 -8
  2. package/dist/browser/content/FileGrid.js.map +1 -1
  3. package/dist/browser/content/MarkdownView.js +21 -7
  4. package/dist/browser/content/MarkdownView.js.map +1 -1
  5. package/dist/browser/content/MessageRow.js +1250 -0
  6. package/dist/browser/content/MessageRow.js.map +1 -0
  7. package/dist/browser/content/VirtualFeed.js +1127 -0
  8. package/dist/browser/content/VirtualFeed.js.map +1 -0
  9. package/dist/browser/content/collection-selection.js +27 -11
  10. package/dist/browser/content/collection-selection.js.map +1 -1
  11. package/dist/browser/content/index.js +3 -0
  12. package/dist/browser/content/index.js.map +1 -1
  13. package/dist/browser/content/markdown-info-blocks.js +363 -0
  14. package/dist/browser/content/markdown-info-blocks.js.map +1 -0
  15. package/dist/browser/feedback/InstallGuide.js +199 -145
  16. package/dist/browser/feedback/InstallGuide.js.map +1 -1
  17. package/dist/browser/feedback/dialog-core.js +6 -1
  18. package/dist/browser/feedback/dialog-core.js.map +1 -1
  19. package/dist/browser/feedback/install.js +8 -4
  20. package/dist/browser/feedback/install.js.map +1 -1
  21. package/dist/browser/inputs/QrScanner.js +101 -21
  22. package/dist/browser/inputs/QrScanner.js.map +1 -1
  23. package/dist/browser/intl/messages.js +92 -0
  24. package/dist/browser/intl/messages.js.map +1 -1
  25. package/dist/browser/layout/MobileShell.js +48 -6
  26. package/dist/browser/layout/MobileShell.js.map +1 -1
  27. package/dist/browser/layout/TabBar.js +8 -4
  28. package/dist/browser/layout/TabBar.js.map +1 -1
  29. package/dist/browser/surfaces/Avatar.js +33 -17
  30. package/dist/browser/surfaces/Avatar.js.map +1 -1
  31. package/dist/ssr/content/FileGrid.js +17 -3
  32. package/dist/ssr/content/FileGrid.js.map +1 -1
  33. package/dist/ssr/content/MarkdownView.js +21 -7
  34. package/dist/ssr/content/MarkdownView.js.map +1 -1
  35. package/dist/ssr/content/MessageRow.js +796 -0
  36. package/dist/ssr/content/MessageRow.js.map +1 -0
  37. package/dist/ssr/content/VirtualFeed.js +989 -0
  38. package/dist/ssr/content/VirtualFeed.js.map +1 -0
  39. package/dist/ssr/content/collection-selection.js +27 -11
  40. package/dist/ssr/content/collection-selection.js.map +1 -1
  41. package/dist/ssr/content/index.js +3 -0
  42. package/dist/ssr/content/index.js.map +1 -1
  43. package/dist/ssr/content/markdown-info-blocks.js +363 -0
  44. package/dist/ssr/content/markdown-info-blocks.js.map +1 -0
  45. package/dist/ssr/feedback/InstallGuide.js +150 -98
  46. package/dist/ssr/feedback/InstallGuide.js.map +1 -1
  47. package/dist/ssr/feedback/dialog-core.js +6 -1
  48. package/dist/ssr/feedback/dialog-core.js.map +1 -1
  49. package/dist/ssr/feedback/install.js +8 -4
  50. package/dist/ssr/feedback/install.js.map +1 -1
  51. package/dist/ssr/inputs/QrScanner.js +101 -21
  52. package/dist/ssr/inputs/QrScanner.js.map +1 -1
  53. package/dist/ssr/intl/messages.js +92 -0
  54. package/dist/ssr/intl/messages.js.map +1 -1
  55. package/dist/ssr/layout/MobileShell.js +48 -6
  56. package/dist/ssr/layout/MobileShell.js.map +1 -1
  57. package/dist/ssr/layout/TabBar.js +3 -2
  58. package/dist/ssr/layout/TabBar.js.map +1 -1
  59. package/dist/ssr/surfaces/Avatar.js +14 -1
  60. package/dist/ssr/surfaces/Avatar.js.map +1 -1
  61. package/dist/styles.css +1 -1
  62. package/dist/types/content/FileGrid.d.ts +6 -1
  63. package/dist/types/content/MarkdownView.d.ts +14 -2
  64. package/dist/types/content/MessageRow.d.ts +168 -0
  65. package/dist/types/content/VirtualFeed.d.ts +81 -0
  66. package/dist/types/content/collection-selection.d.ts +15 -1
  67. package/dist/types/content/index.d.ts +5 -0
  68. package/dist/types/content/markdown-info-blocks.d.ts +67 -0
  69. package/dist/types/feedback/InstallGuide.d.ts +5 -3
  70. package/dist/types/feedback/install.d.ts +4 -3
  71. package/dist/types/inputs/QrScanner.d.ts +10 -2
  72. package/dist/types/intl/messages.d.ts +46 -0
  73. package/dist/types/layout/TabBar.d.ts +4 -1
  74. package/package.json +1 -1
@@ -7,18 +7,87 @@ var _tmpl$ = ["<p class=\"k2b-qr-scanner__note\">", "</p>"],
7
7
  import { timed } from "@k2b/stdlib/solid";
8
8
  import { createSignal, onCleanup, onMount, Show } from "solid-js";
9
9
  import { useUiMessages } from "../intl/messages";
10
+
10
11
  /**
11
- * qr-scanner turns every getUserMedia failure into the string "Camera not found.", so only the Permissions API can
12
- * tell a refusal apart. Safari and Chrome expose the camera permission; elsewhere a refusal counts as unavailable.
12
+ * Why the scanner could not start, as far as the browser tells: `"denied"` when camera access is off, `"no-camera"`
13
+ * when the device has no camera, `"in-use"` when the camera is busy or could not start, most often because another app
14
+ * holds it, and `"unavailable"` for anything else, including a scanning engine that cannot load.
13
15
  */
14
- const cameraDenied = async () => {
15
- try {
16
- return (await navigator.permissions.query({
17
- name: "camera"
18
- })).state === "denied";
19
- } catch {
20
- return false;
16
+
17
+ const sizes = [{
18
+ width: {
19
+ min: 1024
20
+ }
21
+ }, {
22
+ width: {
23
+ min: 768
24
+ }
25
+ }, {}];
26
+ /** The rear camera at a resolution that keeps small codes readable, then any camera: the order qr-scanner tries. */
27
+ const cameraRequests = [...sizes.map(size => ({
28
+ ...size,
29
+ facingMode: {
30
+ exact: "environment"
21
31
  }
32
+ })), ...sizes];
33
+ const errorName = error => typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
34
+ const cameraFailure = name => {
35
+ if (name === "NotAllowedError" || name === "SecurityError") return "denied";
36
+ if (name === "NotFoundError" || name === "OverconstrainedError") return "no-camera";
37
+ if (name === "NotReadableError") return "in-use";
38
+ return "unavailable";
39
+ };
40
+
41
+ /** The facing a track reports, or else the one its label names, as qr-scanner reads it. */
42
+ const facingOf = track => {
43
+ if (!track) return undefined;
44
+ const {
45
+ facingMode
46
+ } = track.getSettings();
47
+ if (facingMode) return facingMode;
48
+ if (/rear|back|environment/i.test(track.label)) return "environment";
49
+ if (/front|user|face/i.test(track.label)) return "user";
50
+ return undefined;
51
+ };
52
+
53
+ /**
54
+ * Opens the camera here rather than in qr-scanner, which turns every failure into "Camera not found.". A refusal ends
55
+ * the search, because asking again could show the permission prompt again. Any other failure may belong to the request,
56
+ * such as a camera that cannot start at the larger size, so the next, looser request follows, as in qr-scanner. When
57
+ * none succeeds, a camera that exists but fails outweighs a request no camera could meet. A closed scanner asks no more.
58
+ */
59
+ const openCamera = async closed => {
60
+ if (!navigator.mediaDevices) return {
61
+ error: "unavailable"
62
+ };
63
+ let error = "no-camera";
64
+ for (const video of cameraRequests) {
65
+ if (closed()) break;
66
+ try {
67
+ const stream = await navigator.mediaDevices.getUserMedia({
68
+ video,
69
+ audio: false
70
+ });
71
+ const facing = facingOf(stream.getVideoTracks()[0]);
72
+ // Like qr-scanner, mirror a front camera, and a camera of unknown facing that the rear request did not find.
73
+ return {
74
+ stream,
75
+ mirrored: facing ? facing === "user" : !video.facingMode
76
+ };
77
+ } catch (failure) {
78
+ const reason = cameraFailure(errorName(failure));
79
+ if (reason === "denied") return {
80
+ error: reason
81
+ };
82
+ if (reason !== "no-camera") error = reason;
83
+ }
84
+ }
85
+ return {
86
+ error
87
+ };
88
+ };
89
+ const stopStream = stream => {
90
+ for (const track of stream.getTracks()) track.stop();
22
91
  };
23
92
 
24
93
  /**
@@ -38,24 +107,22 @@ export function QrScanner(props) {
38
107
  let video;
39
108
  let scanner;
40
109
  let closed = false;
41
- /** The host has unmounted the scanner; it no longer hears about failures that finish later. */
42
- let unmounted = false;
43
110
  let cleanupMotion;
44
111
  const destroy = () => {
45
112
  closed = true;
46
113
  cleanupMotion?.();
47
114
  resetRejected.cancel();
48
115
  resetFeedback.cancel();
49
- // pause(true) stops existing tracks immediately; destroy also handles a still-pending permission request.
116
+ // pause(true) stops the camera stream immediately.
50
117
  void scanner?.pause(true);
51
118
  scanner?.destroy();
52
119
  scanner = undefined;
53
120
  };
54
- const fail = async () => {
121
+ // Once closed, the host has unmounted the scanner or heard why it stopped; later failures stay silent.
122
+ const fail = reason => {
55
123
  if (closed) return;
56
124
  destroy();
57
- const denied = await cameraDenied();
58
- if (!unmounted) props.onError(denied ? "denied" : "unavailable");
125
+ props.onError(reason);
59
126
  };
60
127
  onMount(() => {
61
128
  const hidden = () => {
@@ -65,7 +132,6 @@ export function QrScanner(props) {
65
132
  document.addEventListener("visibilitychange", hidden);
66
133
  window.addEventListener("pagehide", leaving);
67
134
  onCleanup(() => {
68
- unmounted = true;
69
135
  destroy();
70
136
  document.removeEventListener("visibilitychange", hidden);
71
137
  window.removeEventListener("pagehide", leaving);
@@ -89,12 +155,20 @@ export function QrScanner(props) {
89
155
  setInvalid(true);
90
156
  resetFeedback.debouncedFn();
91
157
  }, {
92
- preferredCamera: "environment",
93
158
  highlightScanRegion: true,
94
- // Empty frames are expected. Never log decoded content or frame data.
159
+ // A frame that cannot be decoded never ends the session: the camera runs, and the next frame may read.
160
+ // An engine error other than an empty frame, which the native BarcodeDetector reports as "Scanner error:
161
+ // No QR code found", means the detector cannot read on this device. Fall back to the bundled worker for
162
+ // the page through the flag qr-scanner 1.4.2 sets itself for "not implemented" and "service unavailable";
163
+ // it swaps engines after this frame. Worker errors carry the same prefix; the flag changes nothing there.
164
+ // qr-scanner reports frames only as strings: an Error means the engine itself did not load, such as a
165
+ // decoder chunk gone after a deploy or a worker that cannot start, and no later frame can read. Never log
166
+ // decoded content or frame data.
95
167
  onDecodeError: error => {
96
- if (closed || error === Engine.NO_QR_CODE_FOUND) return;
97
- void fail();
168
+ if (typeof error !== "string") return fail("unavailable");
169
+ if (error.startsWith("Scanner error: ") && !error.endsWith(Engine.NO_QR_CODE_FOUND)) {
170
+ Reflect.set(Engine, "_disableBarcodeDetector", true);
171
+ }
98
172
  }
99
173
  });
100
174
  const motion = matchMedia("(prefers-reduced-motion: reduce)");
@@ -108,10 +182,16 @@ export function QrScanner(props) {
108
182
  applyMotion();
109
183
  motion.addEventListener("change", applyMotion);
110
184
  cleanupMotion = () => motion.removeEventListener("change", applyMotion);
185
+ const camera = await openCamera(() => closed);
186
+ if ("error" in camera) return fail(camera.error);
187
+ if (closed) return stopStream(camera.stream);
188
+ // qr-scanner plays a stream that is already set instead of opening its own.
189
+ video.srcObject = camera.stream;
190
+ video.style.transform = camera.mirrored ? "scaleX(-1)" : "";
111
191
  await scanner.start();
112
192
  if (!closed) setStarting(false);
113
193
  } catch {
114
- await fail();
194
+ fail("unavailable");
115
195
  }
116
196
  })();
117
197
  });
@@ -1 +1 @@
1
- {"version":3,"names":[],"sources":["inputs/QrScanner.tsx"],"sourcesContent":["import { timed } from \"@k2b/stdlib/solid\";\nimport type QrScannerEngine from \"qr-scanner\";\nimport { createSignal, type JSX, onCleanup, onMount, Show } from \"solid-js\";\nimport { useUiMessages } from \"../intl/messages\";\n\nexport type QrScannerError = \"denied\" | \"unavailable\";\n\nexport type QrScannerProps = {\n /** Decoded text is untrusted. Return `true` to accept it and stop the camera, `false` to reject it and keep scanning. */\n onResult: (text: string) => boolean;\n /** The page was hidden or left; unmount the scanner. */\n onStop: () => void;\n /** The camera could not start: access was denied, or there is no usable camera. Offer another way in. */\n onError: (reason: QrScannerError) => void;\n /** What to point the camera at. Defaults to a generic QR code hint. */\n instructions?: string;\n class?: string;\n};\n\n/**\n * qr-scanner turns every getUserMedia failure into the string \"Camera not found.\", so only the Permissions API can\n * tell a refusal apart. Safari and Chrome expose the camera permission; elsewhere a refusal counts as unavailable.\n */\nconst cameraDenied = async (): Promise<boolean> => {\n try {\n return (await navigator.permissions.query({ name: \"camera\" as PermissionName })).state === \"denied\";\n } catch {\n return false;\n }\n};\n\n/**\n * Scans a QR code with the rear camera. Each mount owns exactly one camera session, which stops when the scanner\n * unmounts, the page is hidden, or a result is accepted. The scanning engine loads only when the scanner mounts.\n * Decoded content is never logged.\n */\nexport function QrScanner(props: QrScannerProps): JSX.Element {\n const messages = useUiMessages();\n const [starting, setStarting] = createSignal(true);\n const [invalid, setInvalid] = createSignal(false);\n let lastRejected: string | undefined;\n const resetRejected = timed.debounce(() => {\n lastRejected = undefined;\n }, 1000);\n const resetFeedback = timed.debounce(() => setInvalid(false), 1500);\n let video!: HTMLVideoElement;\n let scanner: QrScannerEngine | undefined;\n let closed = false;\n /** The host has unmounted the scanner; it no longer hears about failures that finish later. */\n let unmounted = false;\n let cleanupMotion: (() => void) | undefined;\n const destroy = () => {\n closed = true;\n cleanupMotion?.();\n resetRejected.cancel();\n resetFeedback.cancel();\n // pause(true) stops existing tracks immediately; destroy also handles a still-pending permission request.\n void scanner?.pause(true);\n scanner?.destroy();\n scanner = undefined;\n };\n const fail = async () => {\n if (closed) return;\n destroy();\n const denied = await cameraDenied();\n if (!unmounted) props.onError(denied ? \"denied\" : \"unavailable\");\n };\n onMount(() => {\n const hidden = () => {\n if (document.visibilityState !== \"visible\") props.onStop();\n };\n const leaving = () => props.onStop();\n document.addEventListener(\"visibilitychange\", hidden);\n window.addEventListener(\"pagehide\", leaving);\n onCleanup(() => {\n unmounted = true;\n destroy();\n document.removeEventListener(\"visibilitychange\", hidden);\n window.removeEventListener(\"pagehide\", leaving);\n });\n void (async () => {\n try {\n const { default: Engine } = await import(\"qr-scanner\");\n if (closed || document.visibilityState !== \"visible\") return;\n scanner = new Engine(\n video,\n (result) => {\n if (closed) return;\n // Rearm only after a full second without a decoded QR, not between individual frames.\n resetRejected.debouncedFn();\n if (result.data === lastRejected) return;\n if (props.onResult(result.data)) {\n destroy();\n return;\n }\n lastRejected = result.data;\n setInvalid(true);\n resetFeedback.debouncedFn();\n },\n {\n preferredCamera: \"environment\",\n highlightScanRegion: true,\n // Empty frames are expected. Never log decoded content or frame data.\n onDecodeError: (error) => {\n if (closed || error === Engine.NO_QR_CODE_FOUND) return;\n void fail();\n },\n },\n );\n const motion = matchMedia(\"(prefers-reduced-motion: reduce)\");\n const applyMotion = () => {\n for (const animation of scanner?.$overlay?.getAnimations({ subtree: true }) ?? []) {\n if (motion.matches) animation.pause();\n else animation.play();\n }\n };\n applyMotion();\n motion.addEventListener(\"change\", applyMotion);\n cleanupMotion = () => motion.removeEventListener(\"change\", applyMotion);\n await scanner.start();\n if (!closed) setStarting(false);\n } catch {\n await fail();\n }\n })();\n });\n return (\n <div class={props.class ? `k2b-qr-scanner ${props.class}` : \"k2b-qr-scanner\"}>\n <div class=\"k2b-qr-scanner__preview\" data-invalid={invalid() || undefined}>\n {/* Live camera preview contains no prerecorded speech or audio. */}\n <video ref={video} muted playsinline aria-label={messages().qrCameraPreview} />\n </div>\n <p role=\"status\">{starting() ? messages().qrCameraStarting : (props.instructions ?? messages().qrCameraInstructions)}</p>\n <Show when={!starting()}>\n <p class=\"k2b-qr-scanner__note\">{messages().qrCameraPrivacy}</p>\n </Show>\n </div>\n );\n}\n\nexport default QrScanner;\n"],"mappings":";;;;;;AAAA,SAAS,KAAK,QAAQ,mBAAmB;AAEzC,SAAS,YAAY,EAAY,SAAS,EAAE,OAAO,EAAE,IAAI,QAAQ,UAAU;AAC3E,SAAS,aAAa,QAAQ,kBAAkB;AAgBhD;AACA;AACA;AACA;AACA,MAAM,YAAY,GAAG,YAA8B;EACjD,IAAI;IACF,OAAO,CAAC,MAAM,SAAS,CAAC,WAAW,CAAC,KAAK,CAAC;MAAE,IAAI,EAAE;IAA2B,CAAC,CAAC,EAAE,KAAK,KAAK,QAAQ;EACrG,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;AACF,CAAC;;AAED;AACA;AACA;AACA;AACA;AACA,OAAO,SAAS,SAAS,CAAC,KAAqB,EAAe;EAC5D,MAAM,QAAQ,GAAG,aAAa,CAAC,CAAC;EAChC,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC;EAClD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,YAAY,CAAC,KAAK,CAAC;EACjD,IAAI,YAAgC;EACpC,MAAM,aAAa,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM;IACzC,YAAY,GAAG,SAAS;EAC1B,CAAC,EAAE,IAAI,CAAC;EACR,MAAM,aAAa,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,UAAU,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC;EACnE,IAAI,KAAwB;EAC5B,IAAI,OAAoC;EACxC,IAAI,MAAM,GAAG,KAAK;EAClB;EACA,IAAI,SAAS,GAAG,KAAK;EACrB,IAAI,aAAuC;EAC3C,MAAM,OAAO,GAAG,MAAM;IACpB,MAAM,GAAG,IAAI;IACb,aAAa,GAAG,CAAC;IACjB,aAAa,CAAC,MAAM,CAAC,CAAC;IACtB,aAAa,CAAC,MAAM,CAAC,CAAC;IACtB;IACA,KAAK,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC,CAAC;IAClB,OAAO,GAAG,SAAS;EACrB,CAAC;EACD,MAAM,IAAI,GAAG,YAAY;IACvB,IAAI,MAAM,EAAE;IACZ,OAAO,CAAC,CAAC;IACT,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,CAAC;IACnC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,QAAQ,GAAG,aAAa,CAAC;EAClE,CAAC;EACD,OAAO,CAAC,MAAM;IACZ,MAAM,MAAM,GAAG,MAAM;MACnB,IAAI,QAAQ,CAAC,eAAe,KAAK,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC5D,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,CAAC;IACpC,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACrD,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,OAAO,CAAC;IAC5C,SAAS,CAAC,MAAM;MACd,SAAS,GAAG,IAAI;MAChB,OAAO,CAAC,CAAC;MACT,QAAQ,CAAC,mBAAmB,CAAC,kBAAkB,EAAE,MAAM,CAAC;MACxD,MAAM,CAAC,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC;IACjD,CAAC,CAAC;IACF,KAAK,CAAC,YAAY;MAChB,IAAI;QACF,MAAM;UAAE,OAAO,EAAE;QAAO,CAAC,GAAG,MAAM,OAAO,YAAY,CAAC;QACtD,IAAI,MAAM,IAAI,QAAQ,CAAC,eAAe,KAAK,SAAS,EAAE;QACtD,OAAO,GAAG,IAAI,MAAM,CAClB,KAAK,EACJ,MAAM,IAAK;UACV,IAAI,MAAM,EAAE;UACZ;UACA,aAAa,CAAC,WAAW,CAAC,CAAC;UAC3B,IAAI,MAAM,CAAC,IAAI,KAAK,YAAY,EAAE;UAClC,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE;YAC/B,OAAO,CAAC,CAAC;YACT;UACF;UACA,YAAY,GAAG,MAAM,CAAC,IAAI;UAC1B,UAAU,CAAC,IAAI,CAAC;UAChB,aAAa,CAAC,WAAW,CAAC,CAAC;QAC7B,CAAC,EACD;UACE,eAAe,EAAE,aAAa;UAC9B,mBAAmB,EAAE,IAAI;UACzB;UACA,aAAa,EAAG,KAAK,IAAK;YACxB,IAAI,MAAM,IAAI,KAAK,KAAK,MAAM,CAAC,gBAAgB,EAAE;YACjD,KAAK,IAAI,CAAC,CAAC;UACb;QACF,CACF,CAAC;QACD,MAAM,MAAM,GAAG,UAAU,CAAC,kCAAkC,CAAC;QAC7D,MAAM,WAAW,GAAG,MAAM;UACxB,KAAK,MAAM,SAAS,IAAI,OAAO,EAAE,QAAQ,EAAE,aAAa,CAAC;YAAE,OAAO,EAAE;UAAK,CAAC,CAAC,IAAI,EAAE,EAAE;YACjF,IAAI,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,KACjC,SAAS,CAAC,IAAI,CAAC,CAAC;UACvB;QACF,CAAC;QACD,WAAW,CAAC,CAAC;QACb,MAAM,CAAC,gBAAgB,CAAC,QAAQ,EAAE,WAAW,CAAC;QAC9C,aAAa,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,WAAW,CAAC;QACvE,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC;QACrB,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,KAAK,CAAC;MACjC,CAAC,CAAC,MAAM;QACN,MAAM,IAAI,CAAC,CAAC;MACd;IACF,CAAC,EAAE,CAAC;EACN,CAAC,CAAC;EACF,8CACc,KAAK,CAAC,KAAK,GAAG,2BAAkB,KAAK,CAAC,KAAK,SAAE,GAAG,gBAAgB,kDACvB,OAAO,CAAC,CAAC,IAAI,SAAS,uDAEtB,QAAQ,CAAC,CAAC,CAAC,eAAe,iBAE3D,QAAQ,CAAC,CAAC,YAAG,QAAQ,CAAC,CAAC,CAAC,gBAAgB,aAAI,KAAK,CAAC,YAAY,IAAI,QAAQ,CAAC,CAAC,CAAC,oBAAoB,CAAC,6BACnH,IAAI;IAAA,IAAC,IAAI;MAAA,OAAE,CAAC,QAAQ,CAAC,CAAC;IAAA;IAAA;MAAA,8BACY,QAAQ,CAAC,CAAC,CAAC,eAAe;IAAA;EAAA;AAInE;AAEA,eAAe,SAAS","ignoreList":[]}
1
+ {"version":3,"names":[],"sources":["inputs/QrScanner.tsx"],"sourcesContent":["import { timed } from \"@k2b/stdlib/solid\";\nimport type QrScannerEngine from \"qr-scanner\";\nimport { createSignal, type JSX, onCleanup, onMount, Show } from \"solid-js\";\nimport { useUiMessages } from \"../intl/messages\";\n\n/**\n * Why the scanner could not start, as far as the browser tells: `\"denied\"` when camera access is off, `\"no-camera\"`\n * when the device has no camera, `\"in-use\"` when the camera is busy or could not start, most often because another app\n * holds it, and `\"unavailable\"` for anything else, including a scanning engine that cannot load.\n */\nexport type QrScannerError = \"denied\" | \"no-camera\" | \"in-use\" | \"unavailable\";\n\nexport type QrScannerProps = {\n /** Decoded text is untrusted. Return `true` to accept it and stop the camera, `false` to reject it and keep scanning. */\n onResult: (text: string) => boolean;\n /** The page was hidden or left; unmount the scanner. */\n onStop: () => void;\n /**\n * The camera or the scanning engine could not start; the scanner has stopped. Offer another way in. A frame that\n * cannot be decoded never ends here.\n */\n onError: (reason: QrScannerError) => void;\n /** What to point the camera at. Defaults to a generic QR code hint. */\n instructions?: string;\n class?: string;\n};\n\nconst sizes: MediaTrackConstraints[] = [{ width: { min: 1024 } }, { width: { min: 768 } }, {}];\n/** The rear camera at a resolution that keeps small codes readable, then any camera: the order qr-scanner tries. */\nconst cameraRequests: MediaTrackConstraints[] = [...sizes.map((size) => ({ ...size, facingMode: { exact: \"environment\" } })), ...sizes];\n\nconst errorName = (error: unknown): string => (typeof error === \"object\" && error !== null && \"name\" in error ? String(error.name) : \"\");\n\nconst cameraFailure = (name: string): QrScannerError => {\n if (name === \"NotAllowedError\" || name === \"SecurityError\") return \"denied\";\n if (name === \"NotFoundError\" || name === \"OverconstrainedError\") return \"no-camera\";\n if (name === \"NotReadableError\") return \"in-use\";\n return \"unavailable\";\n};\n\n/** The facing a track reports, or else the one its label names, as qr-scanner reads it. */\nconst facingOf = (track: MediaStreamTrack | undefined): string | undefined => {\n if (!track) return undefined;\n const { facingMode } = track.getSettings();\n if (facingMode) return facingMode;\n if (/rear|back|environment/i.test(track.label)) return \"environment\";\n if (/front|user|face/i.test(track.label)) return \"user\";\n return undefined;\n};\n\n/**\n * Opens the camera here rather than in qr-scanner, which turns every failure into \"Camera not found.\". A refusal ends\n * the search, because asking again could show the permission prompt again. Any other failure may belong to the request,\n * such as a camera that cannot start at the larger size, so the next, looser request follows, as in qr-scanner. When\n * none succeeds, a camera that exists but fails outweighs a request no camera could meet. A closed scanner asks no more.\n */\nconst openCamera = async (closed: () => boolean): Promise<{ stream: MediaStream; mirrored: boolean } | { error: QrScannerError }> => {\n if (!navigator.mediaDevices) return { error: \"unavailable\" };\n let error: QrScannerError = \"no-camera\";\n for (const video of cameraRequests) {\n if (closed()) break;\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ video, audio: false });\n const facing = facingOf(stream.getVideoTracks()[0]);\n // Like qr-scanner, mirror a front camera, and a camera of unknown facing that the rear request did not find.\n return { stream, mirrored: facing ? facing === \"user\" : !video.facingMode };\n } catch (failure) {\n const reason = cameraFailure(errorName(failure));\n if (reason === \"denied\") return { error: reason };\n if (reason !== \"no-camera\") error = reason;\n }\n }\n return { error };\n};\n\nconst stopStream = (stream: MediaStream) => {\n for (const track of stream.getTracks()) track.stop();\n};\n\n/**\n * Scans a QR code with the rear camera. Each mount owns exactly one camera session, which stops when the scanner\n * unmounts, the page is hidden, or a result is accepted. The scanning engine loads only when the scanner mounts.\n * Decoded content is never logged.\n */\nexport function QrScanner(props: QrScannerProps): JSX.Element {\n const messages = useUiMessages();\n const [starting, setStarting] = createSignal(true);\n const [invalid, setInvalid] = createSignal(false);\n let lastRejected: string | undefined;\n const resetRejected = timed.debounce(() => {\n lastRejected = undefined;\n }, 1000);\n const resetFeedback = timed.debounce(() => setInvalid(false), 1500);\n let video!: HTMLVideoElement;\n let scanner: QrScannerEngine | undefined;\n let closed = false;\n let cleanupMotion: (() => void) | undefined;\n const destroy = () => {\n closed = true;\n cleanupMotion?.();\n resetRejected.cancel();\n resetFeedback.cancel();\n // pause(true) stops the camera stream immediately.\n void scanner?.pause(true);\n scanner?.destroy();\n scanner = undefined;\n };\n // Once closed, the host has unmounted the scanner or heard why it stopped; later failures stay silent.\n const fail = (reason: QrScannerError) => {\n if (closed) return;\n destroy();\n props.onError(reason);\n };\n onMount(() => {\n const hidden = () => {\n if (document.visibilityState !== \"visible\") props.onStop();\n };\n const leaving = () => props.onStop();\n document.addEventListener(\"visibilitychange\", hidden);\n window.addEventListener(\"pagehide\", leaving);\n onCleanup(() => {\n destroy();\n document.removeEventListener(\"visibilitychange\", hidden);\n window.removeEventListener(\"pagehide\", leaving);\n });\n void (async () => {\n try {\n const { default: Engine } = await import(\"qr-scanner\");\n if (closed || document.visibilityState !== \"visible\") return;\n scanner = new Engine(\n video,\n (result) => {\n if (closed) return;\n // Rearm only after a full second without a decoded QR, not between individual frames.\n resetRejected.debouncedFn();\n if (result.data === lastRejected) return;\n if (props.onResult(result.data)) {\n destroy();\n return;\n }\n lastRejected = result.data;\n setInvalid(true);\n resetFeedback.debouncedFn();\n },\n {\n highlightScanRegion: true,\n // A frame that cannot be decoded never ends the session: the camera runs, and the next frame may read.\n // An engine error other than an empty frame, which the native BarcodeDetector reports as \"Scanner error:\n // No QR code found\", means the detector cannot read on this device. Fall back to the bundled worker for\n // the page through the flag qr-scanner 1.4.2 sets itself for \"not implemented\" and \"service unavailable\";\n // it swaps engines after this frame. Worker errors carry the same prefix; the flag changes nothing there.\n // qr-scanner reports frames only as strings: an Error means the engine itself did not load, such as a\n // decoder chunk gone after a deploy or a worker that cannot start, and no later frame can read. Never log\n // decoded content or frame data.\n onDecodeError: (error) => {\n if (typeof error !== \"string\") return fail(\"unavailable\");\n if (error.startsWith(\"Scanner error: \") && !error.endsWith(Engine.NO_QR_CODE_FOUND)) {\n Reflect.set(Engine, \"_disableBarcodeDetector\", true);\n }\n },\n },\n );\n const motion = matchMedia(\"(prefers-reduced-motion: reduce)\");\n const applyMotion = () => {\n for (const animation of scanner?.$overlay?.getAnimations({ subtree: true }) ?? []) {\n if (motion.matches) animation.pause();\n else animation.play();\n }\n };\n applyMotion();\n motion.addEventListener(\"change\", applyMotion);\n cleanupMotion = () => motion.removeEventListener(\"change\", applyMotion);\n const camera = await openCamera(() => closed);\n if (\"error\" in camera) return fail(camera.error);\n if (closed) return stopStream(camera.stream);\n // qr-scanner plays a stream that is already set instead of opening its own.\n video.srcObject = camera.stream;\n video.style.transform = camera.mirrored ? \"scaleX(-1)\" : \"\";\n await scanner.start();\n if (!closed) setStarting(false);\n } catch {\n fail(\"unavailable\");\n }\n })();\n });\n return (\n <div class={props.class ? `k2b-qr-scanner ${props.class}` : \"k2b-qr-scanner\"}>\n <div class=\"k2b-qr-scanner__preview\" data-invalid={invalid() || undefined}>\n {/* Live camera preview contains no prerecorded speech or audio. */}\n <video ref={video} muted playsinline aria-label={messages().qrCameraPreview} />\n </div>\n <p role=\"status\">{starting() ? messages().qrCameraStarting : (props.instructions ?? messages().qrCameraInstructions)}</p>\n <Show when={!starting()}>\n <p class=\"k2b-qr-scanner__note\">{messages().qrCameraPrivacy}</p>\n </Show>\n </div>\n );\n}\n\nexport default QrScanner;\n"],"mappings":"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","ignoreList":[]}
@@ -38,6 +38,11 @@ const uiMessages = i18n.define({
38
38
  close: "Close",
39
39
  closeDialog: "close dialog",
40
40
  copied: "Copied",
41
+ infoBlockNote: "Note",
42
+ infoBlockInfo: "Info",
43
+ infoBlockSuccess: "Success",
44
+ infoBlockWarning: "Warning",
45
+ infoBlockDanger: "Danger",
41
46
  copy: "Copy",
42
47
  closeLightbox: "Close lightbox",
43
48
  couldNotLoadData: "Could not load this data",
@@ -192,6 +197,42 @@ const uiMessages = i18n.define({
192
197
  dropFilesToAttach: "Drop files to attach",
193
198
  conversation: "Conversation",
194
199
  jumpToLatest: "Jump to latest",
200
+ feedJumpToLatest: "Jump to latest",
201
+ feedJumpToLatestCount: ({
202
+ count
203
+ }) => `Jump to latest, ${count} new`,
204
+ feedNewItems: ({
205
+ count
206
+ }) => count === 1 ? "1 new item" : `${count} new items`,
207
+ feedNewMarker: "New",
208
+ messageSent: "Sent",
209
+ messageNotSent: "Not sent",
210
+ messageRetry: "Retry",
211
+ messageShowMore: "Show more",
212
+ messageShowLess: "Show less",
213
+ messageReplyTo: "In reply to",
214
+ messageForwarded: "Forwarded",
215
+ messageEdited: "edited",
216
+ messageDeleted: "This message was deleted",
217
+ messageVideo: "Video",
218
+ messageMoreAttachments: ({
219
+ count
220
+ }) => `${count} more`,
221
+ messageReactions: "Reactions",
222
+ messageReaction: ({
223
+ emoji,
224
+ count,
225
+ names,
226
+ own
227
+ }) => `${emoji} ${own && count === 1 ? "your reaction" : `${count} ${count === 1 ? "reaction" : "reactions"}${own ? ", including yours" : ""}`}${names ? `: ${names}` : ""}`,
228
+ messageAddReaction: "Add reaction",
229
+ messageReplies: ({
230
+ count
231
+ }) => count === 1 ? "1 reply" : `${count} replies`,
232
+ messageLastReply: ({
233
+ time
234
+ }) => `Last reply ${time}`,
235
+ messageStop: "Stop",
195
236
  conversationMessages: ({
196
237
  label
197
238
  }) => `${label} messages`,
@@ -501,6 +542,11 @@ const uiMessages = i18n.define({
501
542
  installAppleBrowserDetail: "This browser installs through Share → Add to Home Screen. If that isn’t there, open this page in Safari.",
502
543
  installAndroidBrowserTitle: "Chrome works best",
503
544
  installAndroidBrowserDetail: "In this browser, look for “Install” or “Add to Home screen” in the menu. If that isn’t there, open this page in Chrome.",
545
+ installSamsungTitle: "Install with Chrome",
546
+ installSamsungDetail: ({
547
+ appName
548
+ }) => `On Samsung phones, Samsung Internet builds its own app package, which Android 14 and later may block as “built for an older version of Android”. That warning is about the package, not about ${appName}. Chrome installs ${appName} without it. Without Chrome, use this browser’s menu and continue through that warning.`,
549
+ installOpenChrome: "Open in Chrome",
504
550
  installCopyLink: "Copy link",
505
551
  installLinkCopied: "Link copied",
506
552
  installCopyFailed: "The link could not be copied. Copy this address manually:",
@@ -544,6 +590,11 @@ const uiMessages = i18n.define({
544
590
  close: "Schließen",
545
591
  closeDialog: "Dialog schließen",
546
592
  copied: "Kopiert",
593
+ infoBlockNote: "Notiz",
594
+ infoBlockInfo: "Info",
595
+ infoBlockSuccess: "Erfolg",
596
+ infoBlockWarning: "Warnung",
597
+ infoBlockDanger: "Gefahr",
547
598
  copy: "Kopieren",
548
599
  closeLightbox: "Bildansicht schließen",
549
600
  couldNotLoadData: "Diese Daten konnten nicht geladen werden",
@@ -698,6 +749,42 @@ const uiMessages = i18n.define({
698
749
  dropFilesToAttach: "Dateien zum Anhängen ablegen",
699
750
  conversation: "Unterhaltung",
700
751
  jumpToLatest: "Zur neuesten Nachricht",
752
+ feedJumpToLatest: "Zum Neuesten",
753
+ feedJumpToLatestCount: ({
754
+ count
755
+ }) => `Zum Neuesten, ${count} neu`,
756
+ feedNewItems: ({
757
+ count
758
+ }) => count === 1 ? "1 neuer Eintrag" : `${count} neue Einträge`,
759
+ feedNewMarker: "Neu",
760
+ messageSent: "Gesendet",
761
+ messageNotSent: "Nicht gesendet",
762
+ messageRetry: "Erneut senden",
763
+ messageShowMore: "Mehr anzeigen",
764
+ messageShowLess: "Weniger anzeigen",
765
+ messageReplyTo: "Antwort auf",
766
+ messageForwarded: "Weitergeleitet",
767
+ messageEdited: "bearbeitet",
768
+ messageDeleted: "Diese Nachricht wurde gelöscht",
769
+ messageVideo: "Video",
770
+ messageMoreAttachments: ({
771
+ count
772
+ }) => `${count} weitere`,
773
+ messageReactions: "Reaktionen",
774
+ messageReaction: ({
775
+ emoji,
776
+ count,
777
+ names,
778
+ own
779
+ }) => `${emoji} ${own && count === 1 ? "deine Reaktion" : `${count} ${count === 1 ? "Reaktion" : "Reaktionen"}${own ? ", einschließlich deiner" : ""}`}${names ? `: ${names}` : ""}`,
780
+ messageAddReaction: "Reaktion hinzufügen",
781
+ messageReplies: ({
782
+ count
783
+ }) => count === 1 ? "1 Antwort" : `${count} Antworten`,
784
+ messageLastReply: ({
785
+ time
786
+ }) => `Letzte Antwort ${time}`,
787
+ messageStop: "Stoppen",
701
788
  conversationMessages: ({
702
789
  label
703
790
  }) => `Nachrichten in ${label}`,
@@ -1007,6 +1094,11 @@ const uiMessages = i18n.define({
1007
1094
  installAppleBrowserDetail: "In diesem Browser installierst du über Teilen → Zum Home-Bildschirm. Fehlt der Eintrag, öffne diese Seite in Safari.",
1008
1095
  installAndroidBrowserTitle: "Am besten mit Chrome",
1009
1096
  installAndroidBrowserDetail: "Suche in diesem Browser im Menü nach „Installieren“ oder „Zum Startbildschirm hinzufügen“. Fehlt der Eintrag, öffne diese Seite in Chrome.",
1097
+ installSamsungTitle: "Mit Chrome installieren",
1098
+ installSamsungDetail: ({
1099
+ appName
1100
+ }) => `Auf Samsung-Handys erstellt Samsung Internet ein eigenes App-Paket, das Android ab Version 14 als „für eine ältere Android-Version entwickelt“ blockieren kann. Diese Warnung betrifft das Paket, nicht ${appName}. Mit Chrome installierst du ${appName} ohne sie. Ohne Chrome nutze das Menü dieses Browsers und fahre trotz dieser Warnung fort.`,
1101
+ installOpenChrome: "In Chrome öffnen",
1010
1102
  installCopyLink: "Link kopieren",
1011
1103
  installLinkCopied: "Link kopiert",
1012
1104
  installCopyFailed: "Der Link konnte nicht kopiert werden. Kopiere diese Adresse manuell:",