@privateclaw/privateclaw-relay 0.1.2 → 0.1.4

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.
@@ -0,0 +1,1303 @@
1
+ import { applyTranslations, bindLocaleSelect, onLocaleChange, t } from "./i18n.js?v=20260316-3";
2
+ import {
3
+ createIdentity,
4
+ decodeBase64,
5
+ decodeInviteString,
6
+ getInviteRelayLabel,
7
+ inviteUsesNonDefaultRelay,
8
+ readFileAsAttachment,
9
+ } from "./protocol-web.js?v=20260316-1";
10
+ import { PrivateClawWebSessionClient } from "./session-client.js?v=20260316-1";
11
+
12
+ const MAX_INLINE_ATTACHMENT_BYTES = 5 * 1024 * 1024;
13
+ const QR_SCAN_MAX_DIMENSION = 1440;
14
+ const IDENTITY_STORAGE_KEY = "privateclaw.web.identity";
15
+
16
+ const elements = {
17
+ localeSelect: document.getElementById("chat-locale-select"),
18
+ statusPill: document.getElementById("status-pill"),
19
+ disconnectButton: document.getElementById("disconnect-button"),
20
+ desktopNote: document.getElementById("desktop-note"),
21
+ toggleInviteButton: document.getElementById("toggle-invite-button"),
22
+ statusCopy: document.getElementById("status-copy"),
23
+ connectForm: document.getElementById("connect-form"),
24
+ inviteInput: document.getElementById("invite-input"),
25
+ scanButton: document.getElementById("scan-button"),
26
+ scanImageButton: document.getElementById("scan-image-button"),
27
+ inviteScanInput: document.getElementById("invite-scan-input"),
28
+ connectButton: document.getElementById("connect-button"),
29
+ sessionMeta: document.getElementById("session-meta"),
30
+ providerLabel: document.getElementById("provider-label"),
31
+ expiresLabel: document.getElementById("expires-label"),
32
+ modeLabel: document.getElementById("mode-label"),
33
+ relayLabel: document.getElementById("relay-label"),
34
+ identityLabel: document.getElementById("identity-label"),
35
+ participantCount: document.getElementById("participant-count"),
36
+ participantChips: document.getElementById("participant-chips"),
37
+ emptyState: document.getElementById("empty-state"),
38
+ messageList: document.getElementById("message-list"),
39
+ messagesScroll: document.getElementById("messages-scroll"),
40
+ draftStrip: document.getElementById("draft-strip"),
41
+ draftChipRow: document.getElementById("draft-chip-row"),
42
+ composerForm: document.getElementById("composer-form"),
43
+ composerInput: document.getElementById("composer-input"),
44
+ attachButton: document.getElementById("attach-button"),
45
+ commandButton: document.getElementById("command-button"),
46
+ sendButton: document.getElementById("send-button"),
47
+ fileInput: document.getElementById("file-input"),
48
+ commandSheet: document.getElementById("command-sheet"),
49
+ closeCommandSheet: document.getElementById("close-command-sheet"),
50
+ commandList: document.getElementById("command-list"),
51
+ scannerSheet: document.getElementById("scanner-sheet"),
52
+ closeScannerSheet: document.getElementById("close-scanner-sheet"),
53
+ scannerVideo: document.getElementById("scanner-video"),
54
+ scannerStatus: document.getElementById("scanner-status"),
55
+ scannerUploadButton: document.getElementById("scanner-upload-button"),
56
+ toastStack: document.getElementById("toast-stack"),
57
+ };
58
+
59
+ const state = {
60
+ client: null,
61
+ invite: null,
62
+ messages: [],
63
+ commands: [],
64
+ participants: [],
65
+ selectedAttachments: [],
66
+ status: "idle",
67
+ statusCopy: "",
68
+ showInviteForm: true,
69
+ botMuted: false,
70
+ identity: loadIdentity(),
71
+ objectUrls: new Map(),
72
+ scanner: {
73
+ stream: null,
74
+ frameHandle: null,
75
+ active: false,
76
+ detecting: false,
77
+ },
78
+ };
79
+
80
+ let qrDetectorPromise = null;
81
+ let jsQrDecoderPromise = null;
82
+
83
+ bindLocaleSelect(elements.localeSelect);
84
+
85
+ function readStoredIdentity() {
86
+ try {
87
+ return window.localStorage.getItem(IDENTITY_STORAGE_KEY);
88
+ } catch (error) {
89
+ console.warn("PrivateClaw could not read the stored web identity.", error);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function saveIdentity(identity) {
95
+ try {
96
+ window.localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity));
97
+ } catch (error) {
98
+ console.warn("PrivateClaw could not persist the web identity.", error);
99
+ }
100
+ }
101
+
102
+ function loadIdentity() {
103
+ const stored = readStoredIdentity();
104
+ if (stored) {
105
+ try {
106
+ const parsed = JSON.parse(stored);
107
+ if (parsed && typeof parsed.appId === "string" && parsed.appId.trim() !== "") {
108
+ return {
109
+ appId: parsed.appId,
110
+ displayName: typeof parsed.displayName === "string" ? parsed.displayName : null,
111
+ };
112
+ }
113
+ } catch (error) {
114
+ console.warn("PrivateClaw could not parse the stored web identity.", error);
115
+ }
116
+ }
117
+
118
+ const identity = createIdentity();
119
+ saveIdentity(identity);
120
+ return identity;
121
+ }
122
+
123
+ async function getQrDetector() {
124
+ if (qrDetectorPromise) {
125
+ return qrDetectorPromise;
126
+ }
127
+
128
+ qrDetectorPromise = (async () => {
129
+ const Detector = globalThis.BarcodeDetector;
130
+ if (typeof Detector !== "function") {
131
+ return null;
132
+ }
133
+
134
+ try {
135
+ if (typeof Detector.getSupportedFormats === "function") {
136
+ const supportedFormats = await Detector.getSupportedFormats();
137
+ if (Array.isArray(supportedFormats) && !supportedFormats.includes("qr_code")) {
138
+ return null;
139
+ }
140
+ }
141
+ } catch (error) {
142
+ console.warn("PrivateClaw could not query supported barcode formats.", error);
143
+ }
144
+
145
+ try {
146
+ return new Detector({ formats: ["qr_code"] });
147
+ } catch (error) {
148
+ console.warn("PrivateClaw could not initialize the QR detector.", error);
149
+ return null;
150
+ }
151
+ })();
152
+
153
+ return qrDetectorPromise;
154
+ }
155
+
156
+ async function getJsQrDecoder() {
157
+ if (typeof globalThis.jsQR === "function") {
158
+ return globalThis.jsQR;
159
+ }
160
+
161
+ if (!jsQrDecoderPromise) {
162
+ jsQrDecoderPromise = import("./vendor/jsQR.js")
163
+ .then(() => {
164
+ if (typeof globalThis.jsQR !== "function") {
165
+ throw new Error("jsQR decoder did not initialize.");
166
+ }
167
+ return globalThis.jsQR;
168
+ })
169
+ .catch((error) => {
170
+ jsQrDecoderPromise = null;
171
+ throw error;
172
+ });
173
+ }
174
+
175
+ return jsQrDecoderPromise;
176
+ }
177
+
178
+ function updateScannerStatus(message) {
179
+ elements.scannerStatus.textContent = message;
180
+ }
181
+
182
+ function openScannerSheet() {
183
+ elements.scannerSheet.classList.remove("hidden");
184
+ elements.scannerSheet.hidden = false;
185
+ }
186
+
187
+ async function stopScanner() {
188
+ state.scanner.active = false;
189
+ state.scanner.detecting = false;
190
+ if (state.scanner.frameHandle) {
191
+ cancelAnimationFrame(state.scanner.frameHandle);
192
+ state.scanner.frameHandle = null;
193
+ }
194
+ if (state.scanner.stream) {
195
+ for (const track of state.scanner.stream.getTracks()) {
196
+ track.stop();
197
+ }
198
+ state.scanner.stream = null;
199
+ }
200
+ elements.scannerVideo.pause();
201
+ elements.scannerVideo.srcObject = null;
202
+ }
203
+
204
+ async function closeScannerSheet() {
205
+ await stopScanner();
206
+ elements.scannerSheet.classList.add("hidden");
207
+ elements.scannerSheet.hidden = true;
208
+ updateScannerStatus(t("chat.scannerStatusStarting"));
209
+ }
210
+
211
+ function createScratchContext(width, height) {
212
+ if (typeof OffscreenCanvas === "function") {
213
+ const canvas = new OffscreenCanvas(width, height);
214
+ const context = canvas.getContext("2d", { willReadFrequently: true });
215
+ return context || null;
216
+ }
217
+
218
+ const canvas = document.createElement("canvas");
219
+ canvas.width = width;
220
+ canvas.height = height;
221
+ return canvas.getContext("2d", { willReadFrequently: true });
222
+ }
223
+
224
+ function getImageSourceDimensions(source) {
225
+ if (typeof source.videoWidth === "number" && source.videoWidth > 0 && typeof source.videoHeight === "number" && source.videoHeight > 0) {
226
+ return { width: source.videoWidth, height: source.videoHeight };
227
+ }
228
+ if (typeof source.naturalWidth === "number" && source.naturalWidth > 0 && typeof source.naturalHeight === "number" && source.naturalHeight > 0) {
229
+ return { width: source.naturalWidth, height: source.naturalHeight };
230
+ }
231
+ if (typeof source.width === "number" && source.width > 0 && typeof source.height === "number" && source.height > 0) {
232
+ return { width: source.width, height: source.height };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function getImageDataForQrSource(source, maxDimension = QR_SCAN_MAX_DIMENSION) {
238
+ const dimensions = getImageSourceDimensions(source);
239
+ if (!dimensions) {
240
+ return null;
241
+ }
242
+
243
+ const scale = Math.min(1, maxDimension / Math.max(dimensions.width, dimensions.height));
244
+ const canvasWidth = Math.max(1, Math.round(dimensions.width * scale));
245
+ const canvasHeight = Math.max(1, Math.round(dimensions.height * scale));
246
+ const context = createScratchContext(canvasWidth, canvasHeight);
247
+ if (!context) {
248
+ return null;
249
+ }
250
+
251
+ context.drawImage(source, 0, 0, canvasWidth, canvasHeight);
252
+ return context.getImageData(0, 0, canvasWidth, canvasHeight);
253
+ }
254
+
255
+ function extractDetectedInvite(detections) {
256
+ if (!Array.isArray(detections)) {
257
+ return null;
258
+ }
259
+ for (const detection of detections) {
260
+ const rawValue = typeof detection?.rawValue === "string" ? detection.rawValue.trim() : "";
261
+ if (rawValue) {
262
+ return rawValue;
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ async function completeScannedInvite(rawValue) {
269
+ elements.inviteInput.value = rawValue;
270
+ updateScannerStatus(t("chat.scannerStatusFound"));
271
+ await closeScannerSheet();
272
+ await connectWithInvite(rawValue);
273
+ }
274
+
275
+ async function loadQrImageSource(file) {
276
+ if (typeof createImageBitmap === "function") {
277
+ const bitmap = await createImageBitmap(file);
278
+ return {
279
+ source: bitmap,
280
+ dispose() {
281
+ if (typeof bitmap.close === "function") {
282
+ bitmap.close();
283
+ }
284
+ },
285
+ };
286
+ }
287
+
288
+ const objectUrl = URL.createObjectURL(file);
289
+ const image = new Image();
290
+ image.decoding = "async";
291
+
292
+ try {
293
+ await new Promise((resolve, reject) => {
294
+ image.onload = () => resolve();
295
+ image.onerror = () => reject(new Error("Image failed to load."));
296
+ image.src = objectUrl;
297
+ });
298
+ } catch (error) {
299
+ URL.revokeObjectURL(objectUrl);
300
+ throw error;
301
+ }
302
+
303
+ return {
304
+ source: image,
305
+ dispose() {
306
+ URL.revokeObjectURL(objectUrl);
307
+ },
308
+ };
309
+ }
310
+
311
+ async function detectQrValue(source, { maxDimension = QR_SCAN_MAX_DIMENSION } = {}) {
312
+ const detector = await getQrDetector();
313
+ if (detector) {
314
+ try {
315
+ const detections = await detector.detect(source);
316
+ const rawValue = extractDetectedInvite(detections);
317
+ if (rawValue) {
318
+ return rawValue;
319
+ }
320
+ } catch (error) {
321
+ console.warn("PrivateClaw native QR detection failed; falling back to jsQR.", error);
322
+ }
323
+ }
324
+
325
+ const imageData = getImageDataForQrSource(source, maxDimension);
326
+ if (!imageData) {
327
+ return null;
328
+ }
329
+
330
+ try {
331
+ const jsQr = await getJsQrDecoder();
332
+ const result = jsQr(imageData.data, imageData.width, imageData.height, {
333
+ inversionAttempts: "attemptBoth",
334
+ });
335
+ return typeof result?.data === "string" ? result.data.trim() : null;
336
+ } catch (error) {
337
+ console.warn("PrivateClaw fallback QR detection failed.", error);
338
+ return null;
339
+ }
340
+ }
341
+
342
+ async function scanFromImageFile(file) {
343
+ try {
344
+ const image = await loadQrImageSource(file);
345
+ try {
346
+ const rawValue = await detectQrValue(image.source);
347
+ if (!rawValue) {
348
+ showToast(t("chat.scanNoCodeFound"), { error: true });
349
+ return;
350
+ }
351
+ await completeScannedInvite(rawValue);
352
+ } finally {
353
+ image.dispose();
354
+ }
355
+ } catch (error) {
356
+ console.warn("PrivateClaw could not read a QR image.", error);
357
+ showToast(t("chat.scanReadFailed"), { error: true });
358
+ }
359
+ }
360
+
361
+ async function scanVideoFrame() {
362
+ if (!state.scanner.active) {
363
+ return;
364
+ }
365
+
366
+ if (state.scanner.detecting) {
367
+ state.scanner.frameHandle = requestAnimationFrame(() => {
368
+ void scanVideoFrame();
369
+ });
370
+ return;
371
+ }
372
+
373
+ if (elements.scannerVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
374
+ state.scanner.frameHandle = requestAnimationFrame(() => {
375
+ void scanVideoFrame();
376
+ });
377
+ return;
378
+ }
379
+
380
+ state.scanner.detecting = true;
381
+ try {
382
+ const rawValue = await detectQrValue(elements.scannerVideo, { maxDimension: 960 });
383
+ if (rawValue) {
384
+ await completeScannedInvite(rawValue);
385
+ return;
386
+ }
387
+ } catch (error) {
388
+ console.warn("PrivateClaw camera scan frame failed.", error);
389
+ } finally {
390
+ state.scanner.detecting = false;
391
+ }
392
+
393
+ if (state.scanner.active) {
394
+ state.scanner.frameHandle = requestAnimationFrame(() => {
395
+ void scanVideoFrame();
396
+ });
397
+ }
398
+ }
399
+
400
+ function openScanImagePicker({ preferCamera = false } = {}) {
401
+ if (preferCamera) {
402
+ elements.inviteScanInput.setAttribute("capture", "environment");
403
+ } else {
404
+ elements.inviteScanInput.removeAttribute("capture");
405
+ }
406
+ elements.inviteScanInput.click();
407
+ }
408
+
409
+ async function startScanner() {
410
+ if (!navigator.mediaDevices?.getUserMedia) {
411
+ openScanImagePicker({ preferCamera: isMobileDevice() });
412
+ showToast(t("chat.scanPickerFallback"));
413
+ return;
414
+ }
415
+
416
+ try {
417
+ const stream = await navigator.mediaDevices.getUserMedia({
418
+ video: {
419
+ facingMode: { ideal: "environment" },
420
+ },
421
+ audio: false,
422
+ });
423
+ await stopScanner();
424
+ state.scanner.stream = stream;
425
+ openScannerSheet();
426
+ updateScannerStatus(t("chat.scannerStatusStarting"));
427
+ elements.scannerVideo.srcObject = stream;
428
+ await elements.scannerVideo.play();
429
+ state.scanner.active = true;
430
+ updateScannerStatus(t("chat.scannerStatusScanning"));
431
+ void scanVideoFrame();
432
+ } catch (error) {
433
+ console.warn("PrivateClaw could not start camera scanning.", error);
434
+ await closeScannerSheet();
435
+ const errorName = error instanceof DOMException ? error.name : "";
436
+ const messageKey =
437
+ errorName === "NotAllowedError" || errorName === "SecurityError"
438
+ ? "chat.scanPermissionDenied"
439
+ : "chat.scanCameraUnsupported";
440
+ showToast(t(messageKey), { error: true });
441
+ }
442
+ }
443
+
444
+ function isMobileDevice() {
445
+ const ua = navigator.userAgent || "";
446
+ const coarsePointer = globalThis.matchMedia?.("(pointer: coarse)")?.matches ?? false;
447
+ const narrowScreen = globalThis.matchMedia?.("(max-width: 820px)")?.matches ?? false;
448
+ return coarsePointer || narrowScreen || /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
449
+ }
450
+
451
+ function formatDateTime(value) {
452
+ if (!value) {
453
+ return t("chat.expiresUnknown");
454
+ }
455
+ const date = value instanceof Date ? value : new Date(value);
456
+ if (Number.isNaN(date.valueOf())) {
457
+ return t("chat.expiresUnknown");
458
+ }
459
+ return new Intl.DateTimeFormat(undefined, {
460
+ dateStyle: "medium",
461
+ timeStyle: "short",
462
+ }).format(date);
463
+ }
464
+
465
+ function formatBytes(bytes) {
466
+ if (!Number.isFinite(bytes) || bytes <= 0) {
467
+ return "0 B";
468
+ }
469
+ const units = ["B", "KB", "MB", "GB"];
470
+ let value = bytes;
471
+ let unitIndex = 0;
472
+ while (value >= 1024 && unitIndex < units.length - 1) {
473
+ value /= 1024;
474
+ unitIndex += 1;
475
+ }
476
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
477
+ }
478
+
479
+ function mapClientError(error) {
480
+ const reason = error instanceof Error ? error.message : String(error);
481
+ switch (reason) {
482
+ case "empty_invite":
483
+ case "malformed_invite":
484
+ case "missing_payload":
485
+ case "invite_missing_sessionId":
486
+ case "invite_missing_sessionKey":
487
+ case "invite_missing_appWsUrl":
488
+ case "invite_missing_expiresAt":
489
+ return t("chat.connectFailed");
490
+ case "unsupported_invite_version":
491
+ return t("chat.invalidInviteVersion");
492
+ case "browser_crypto_unavailable":
493
+ return t("chat.browserCryptoUnavailable");
494
+ case "invalid_session_key_length":
495
+ return t("chat.sessionKeyLengthError");
496
+ case "session_not_connected":
497
+ return t("chat.notConnected");
498
+ default:
499
+ return reason;
500
+ }
501
+ }
502
+
503
+ function localizeNotice(notice, details) {
504
+ const normalizedDetails = typeof details === "string" && details.trim() !== "" ? details.trim() : "unknown_error";
505
+ switch (notice) {
506
+ case "connectingRelay":
507
+ return t("chat.relayConnecting");
508
+ case "relayAttached":
509
+ return t("chat.relayHandshake");
510
+ case "connectionError":
511
+ return t("chat.relayConnectionError", { reason: normalizedDetails });
512
+ case "sessionClosed":
513
+ return details
514
+ ? t("chat.relaySessionClosedWithReason", { reason: normalizedDetails })
515
+ : t("chat.relaySessionClosed");
516
+ case "relayError":
517
+ return t("chat.relayError", { reason: normalizedDetails });
518
+ case "unknownRelayEvent":
519
+ return t("chat.relayUnknownEvent", { reason: normalizedDetails });
520
+ case "unknownPayload":
521
+ return t("chat.relayUnknownPayload", { reason: normalizedDetails });
522
+ case "welcome":
523
+ return typeof details === "string" && details.trim() !== "" ? details : t("chat.welcomeFallback");
524
+ default:
525
+ return "";
526
+ }
527
+ }
528
+
529
+ function getStatusLabel(status) {
530
+ switch (status) {
531
+ case "connecting":
532
+ return t("chat.statusLabelConnecting");
533
+ case "reconnecting":
534
+ return t("chat.statusLabelReconnecting");
535
+ case "relayAttached":
536
+ return t("chat.statusLabelRelayAttached");
537
+ case "active":
538
+ return t("chat.statusLabelActive");
539
+ case "closed":
540
+ return t("chat.statusLabelClosed");
541
+ case "error":
542
+ return t("chat.statusLabelError");
543
+ case "idle":
544
+ default:
545
+ return t("chat.statusLabelIdle");
546
+ }
547
+ }
548
+
549
+ function setStatus(status, { notice = null, details = null } = {}) {
550
+ state.status = status;
551
+ if (notice) {
552
+ state.statusCopy = localizeNotice(notice, details);
553
+ } else if (status === "active") {
554
+ state.statusCopy = t("chat.welcomeFallback");
555
+ } else if (status === "idle") {
556
+ state.statusCopy = t("chat.statusIdle");
557
+ }
558
+ }
559
+
560
+ function showToast(message, { error = false } = {}) {
561
+ const toast = document.createElement("div");
562
+ toast.className = `toast${error ? " error" : ""}`;
563
+ toast.textContent = message;
564
+ elements.toastStack.append(toast);
565
+ window.setTimeout(() => {
566
+ toast.remove();
567
+ }, 3200);
568
+ }
569
+
570
+ function autoGrowComposer() {
571
+ elements.composerInput.style.height = "0px";
572
+ elements.composerInput.style.height = `${Math.min(elements.composerInput.scrollHeight, 160)}px`;
573
+ }
574
+
575
+ function clearObjectUrls() {
576
+ for (const url of state.objectUrls.values()) {
577
+ URL.revokeObjectURL(url);
578
+ }
579
+ state.objectUrls.clear();
580
+ }
581
+
582
+ function resetConversationState() {
583
+ clearObjectUrls();
584
+ state.messages = [];
585
+ state.commands = [];
586
+ state.participants = [];
587
+ state.selectedAttachments = [];
588
+ state.botMuted = false;
589
+ elements.fileInput.value = "";
590
+ }
591
+
592
+ function getAttachmentUrl(attachment) {
593
+ if (attachment.uri) {
594
+ return attachment.uri;
595
+ }
596
+ if (!attachment.dataBase64) {
597
+ return null;
598
+ }
599
+ const cacheKey = `${attachment.id}:${attachment.mimeType}:${attachment.sizeBytes}`;
600
+ if (state.objectUrls.has(cacheKey)) {
601
+ return state.objectUrls.get(cacheKey);
602
+ }
603
+ const bytes = decodeBase64(attachment.dataBase64);
604
+ const blob = new Blob([bytes], { type: attachment.mimeType || "application/octet-stream" });
605
+ const url = URL.createObjectURL(blob);
606
+ state.objectUrls.set(cacheKey, url);
607
+ return url;
608
+ }
609
+
610
+ function escapeHtml(value) {
611
+ return value
612
+ .replaceAll("&", "&amp;")
613
+ .replaceAll("<", "&lt;")
614
+ .replaceAll(">", "&gt;")
615
+ .replaceAll('"', "&quot;")
616
+ .replaceAll("'", "&#39;");
617
+ }
618
+
619
+ function renderInlineHtml(value) {
620
+ const escaped = escapeHtml(value);
621
+ const markdownLinks = escaped.replace(
622
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
623
+ '<a href="$2" target="_blank" rel="noreferrer">$1</a>',
624
+ );
625
+ const inlineCode = markdownLinks.replace(/`([^`]+)`/g, "<code>$1</code>");
626
+ return inlineCode.replace(
627
+ /(?<!["'=])(https?:\/\/[^\s<]+)/g,
628
+ '<a href="$1" target="_blank" rel="noreferrer">$1</a>',
629
+ );
630
+ }
631
+
632
+ function renderRichText(text) {
633
+ const fragment = document.createDocumentFragment();
634
+ const source = typeof text === "string" ? text : "";
635
+ const fencePattern = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
636
+ let lastIndex = 0;
637
+ let match = fencePattern.exec(source);
638
+ while (match) {
639
+ if (match.index > lastIndex) {
640
+ fragment.append(renderParagraphSection(source.slice(lastIndex, match.index)));
641
+ }
642
+ const language = (match[1] || "").trim().toLowerCase();
643
+ const code = match[2] || "";
644
+ if (language === "mermaid") {
645
+ const card = document.createElement("div");
646
+ card.className = "mermaid-card";
647
+ const heading = document.createElement("strong");
648
+ heading.textContent = "Mermaid";
649
+ const pre = document.createElement("pre");
650
+ const codeElement = document.createElement("code");
651
+ codeElement.textContent = code.trim();
652
+ pre.append(codeElement);
653
+ card.append(heading, pre);
654
+ fragment.append(card);
655
+ } else {
656
+ const pre = document.createElement("pre");
657
+ const codeElement = document.createElement("code");
658
+ codeElement.textContent = code.trim();
659
+ pre.append(codeElement);
660
+ fragment.append(pre);
661
+ }
662
+ lastIndex = fencePattern.lastIndex;
663
+ match = fencePattern.exec(source);
664
+ }
665
+ if (lastIndex < source.length) {
666
+ fragment.append(renderParagraphSection(source.slice(lastIndex)));
667
+ }
668
+ return fragment;
669
+ }
670
+
671
+ function renderParagraphSection(section) {
672
+ const fragment = document.createDocumentFragment();
673
+ const paragraphs = section
674
+ .split(/\n{2,}/)
675
+ .map((paragraph) => paragraph.trim())
676
+ .filter(Boolean);
677
+ for (const paragraph of paragraphs) {
678
+ const element = document.createElement("p");
679
+ element.innerHTML = renderInlineHtml(paragraph).replace(/\n/g, "<br />");
680
+ fragment.append(element);
681
+ }
682
+ return fragment;
683
+ }
684
+
685
+ function createPendingIndicator() {
686
+ const dots = document.createElement("div");
687
+ dots.className = "pending-dots";
688
+ for (let index = 0; index < 3; index += 1) {
689
+ dots.append(document.createElement("span"));
690
+ }
691
+ return dots;
692
+ }
693
+
694
+ function renderAttachments(attachments) {
695
+ const container = document.createElement("div");
696
+ container.className = "message-attachments";
697
+ for (const attachment of attachments) {
698
+ const card = document.createElement("div");
699
+ card.className = "attachment-card";
700
+ const url = getAttachmentUrl(attachment);
701
+
702
+ if (url && attachment.mimeType.startsWith("image/")) {
703
+ const image = document.createElement("img");
704
+ image.src = url;
705
+ image.alt = attachment.name;
706
+ card.append(image);
707
+ } else if (url && attachment.mimeType.startsWith("audio/")) {
708
+ const audio = document.createElement("audio");
709
+ audio.controls = true;
710
+ audio.src = url;
711
+ card.append(audio);
712
+ } else if (url && attachment.mimeType.startsWith("video/")) {
713
+ const video = document.createElement("video");
714
+ video.controls = true;
715
+ video.playsInline = true;
716
+ video.src = url;
717
+ card.append(video);
718
+ } else if (!url) {
719
+ const fallback = document.createElement("p");
720
+ fallback.textContent = t("chat.attachmentNoPreview");
721
+ card.append(fallback);
722
+ }
723
+
724
+ const meta = document.createElement("div");
725
+ meta.className = "attachment-meta";
726
+
727
+ const metaCopy = document.createElement("div");
728
+ const name = document.createElement("div");
729
+ name.className = "attachment-name";
730
+ name.textContent = attachment.name;
731
+ const size = document.createElement("div");
732
+ size.className = "attachment-size";
733
+ size.textContent = formatBytes(attachment.sizeBytes);
734
+ metaCopy.append(name, size);
735
+
736
+ meta.append(metaCopy);
737
+ if (url) {
738
+ const download = document.createElement("a");
739
+ download.href = url;
740
+ download.target = "_blank";
741
+ download.rel = "noreferrer";
742
+ download.download = attachment.name;
743
+ download.textContent = t("chat.downloadAttachment");
744
+ meta.append(download);
745
+ }
746
+ card.append(meta);
747
+ container.append(card);
748
+ }
749
+ return container;
750
+ }
751
+
752
+ function upsertMessage(message) {
753
+ if (message.isPending && message.replyTo) {
754
+ const repliedIndex = state.messages.findIndex(
755
+ (item) => item.id === message.replyTo && item.sender === "user",
756
+ );
757
+ if (repliedIndex >= 0) {
758
+ state.messages[repliedIndex] = {
759
+ ...state.messages[repliedIndex],
760
+ isPending: true,
761
+ };
762
+ state.messages = state.messages.filter(
763
+ (item) => !(item.sender === "assistant" && item.isPending && item.replyTo === message.replyTo),
764
+ );
765
+ return;
766
+ }
767
+ }
768
+
769
+ const existingIndex = state.messages.findIndex((item) => item.id === message.id);
770
+ if (existingIndex >= 0) {
771
+ const existingMessage = state.messages[existingIndex];
772
+ state.messages[existingIndex] = {
773
+ ...message,
774
+ isPending:
775
+ existingMessage.sender === "user" &&
776
+ existingMessage.id === message.id &&
777
+ existingMessage.isPending
778
+ ? true
779
+ : Boolean(message.isPending),
780
+ };
781
+ return;
782
+ }
783
+
784
+ if (message.isPending) {
785
+ state.messages.push(message);
786
+ return;
787
+ }
788
+
789
+ if (message.replyTo) {
790
+ const repliedIndex = state.messages.findIndex(
791
+ (item) => item.id === message.replyTo && item.sender === "user",
792
+ );
793
+ if (repliedIndex >= 0 && state.messages[repliedIndex].isPending) {
794
+ state.messages[repliedIndex] = {
795
+ ...state.messages[repliedIndex],
796
+ isPending: false,
797
+ };
798
+ }
799
+ state.messages = state.messages.filter(
800
+ (item) => !(item.sender === "assistant" && item.isPending && item.replyTo === message.replyTo),
801
+ );
802
+ }
803
+ state.messages.push(message);
804
+ }
805
+
806
+ function renderMessages() {
807
+ elements.messageList.replaceChildren();
808
+ const hasMessages = state.messages.length > 0;
809
+ elements.emptyState.classList.toggle("hidden", hasMessages);
810
+ elements.emptyState.hidden = hasMessages;
811
+
812
+ for (const message of state.messages) {
813
+ const shell = document.createElement("article");
814
+ const senderType = message.sender === "system"
815
+ ? `system${message.severity === "error" ? " error" : ""}`
816
+ : message.sender === "assistant"
817
+ ? "assistant"
818
+ : message.isOwnMessage
819
+ ? "user own"
820
+ : "user peer";
821
+ shell.className = `message-shell ${senderType}`;
822
+
823
+ const bubble = document.createElement("div");
824
+ bubble.className = "message-bubble";
825
+
826
+ const label = document.createElement("span");
827
+ label.className = "message-label";
828
+ if (message.sender === "assistant") {
829
+ label.textContent = t("chat.assistantLabel");
830
+ } else if (message.sender === "system") {
831
+ label.textContent = t("chat.systemLabel");
832
+ } else if (message.isOwnMessage) {
833
+ label.textContent = state.identity.displayName || t("chat.youLabel");
834
+ } else {
835
+ label.textContent = message.senderLabel || t("chat.peerLabelFallback");
836
+ }
837
+ bubble.append(label);
838
+
839
+ const body = document.createElement("div");
840
+ body.className = "message-text";
841
+ if (message.isPending && message.sender !== "user") {
842
+ const pendingLabel = document.createElement("p");
843
+ pendingLabel.textContent = t("chat.pendingLabel");
844
+ body.append(pendingLabel, createPendingIndicator());
845
+ } else {
846
+ body.append(renderRichText(message.text || ""));
847
+ if (Array.isArray(message.attachments) && message.attachments.length > 0) {
848
+ body.append(renderAttachments(message.attachments));
849
+ }
850
+ if (message.isPending && message.sender === "user") {
851
+ body.append(createPendingIndicator());
852
+ }
853
+ }
854
+ bubble.append(body);
855
+
856
+ const time = document.createElement("div");
857
+ time.className = "message-time";
858
+ time.textContent = formatDateTime(message.sentAt);
859
+
860
+ shell.append(bubble, time);
861
+ elements.messageList.append(shell);
862
+ }
863
+
864
+ elements.messagesScroll.scrollTop = elements.messagesScroll.scrollHeight;
865
+ }
866
+
867
+ function renderParticipants() {
868
+ elements.participantChips.replaceChildren();
869
+ for (const participant of state.participants) {
870
+ const chip = document.createElement("div");
871
+ chip.className = "participant-pill";
872
+ chip.textContent = participant.displayName;
873
+ elements.participantChips.append(chip);
874
+ }
875
+ elements.participantCount.textContent = String(state.participants.length);
876
+ }
877
+
878
+ function renderDraftAttachments() {
879
+ const hasAttachments = state.selectedAttachments.length > 0;
880
+ elements.draftStrip.classList.toggle("hidden", !hasAttachments);
881
+ elements.draftStrip.hidden = !hasAttachments;
882
+ elements.draftChipRow.replaceChildren();
883
+ if (!hasAttachments) {
884
+ return;
885
+ }
886
+ for (const attachment of state.selectedAttachments) {
887
+ const chip = document.createElement("div");
888
+ chip.className = "draft-chip";
889
+
890
+ const label = document.createElement("span");
891
+ label.textContent = `${attachment.name} · ${formatBytes(attachment.sizeBytes)}`;
892
+
893
+ const removeButton = document.createElement("button");
894
+ removeButton.type = "button";
895
+ removeButton.setAttribute("aria-label", t("chat.draftRemoveAttachment"));
896
+ removeButton.textContent = "×";
897
+ removeButton.addEventListener("click", () => {
898
+ state.selectedAttachments = state.selectedAttachments.filter((item) => item.id !== attachment.id);
899
+ renderPage();
900
+ });
901
+
902
+ chip.append(label, removeButton);
903
+ elements.draftChipRow.append(chip);
904
+ }
905
+ }
906
+
907
+ function renderCommands() {
908
+ elements.commandList.replaceChildren();
909
+ for (const command of state.commands) {
910
+ const item = document.createElement("button");
911
+ item.type = "button";
912
+ item.className = "command-item";
913
+
914
+ const title = document.createElement("strong");
915
+ title.textContent = command.slash;
916
+
917
+ const description = document.createElement("p");
918
+ const sourceLabel = command.source === "openclaw"
919
+ ? t("chat.commandSourceOpenclaw")
920
+ : command.source === "plugin"
921
+ ? t("chat.commandSourcePlugin")
922
+ : t("chat.commandSourcePrivateclaw");
923
+ description.textContent = `${command.description} · ${sourceLabel}${command.acceptsArgs ? ` · ${t("chat.commandArgHint")}` : ` · ${t("chat.commandSendNow")}`}`;
924
+
925
+ item.append(title, description);
926
+ item.addEventListener("click", async () => {
927
+ await selectCommand(command);
928
+ });
929
+ elements.commandList.append(item);
930
+ }
931
+ }
932
+
933
+ function renderSessionMeta() {
934
+ const hasInvite = Boolean(state.invite);
935
+ const activeLike = ["connecting", "relayAttached", "reconnecting", "active"].includes(state.status);
936
+ const showMeta = hasInvite && (activeLike || state.messages.length > 0 || state.participants.length > 0);
937
+
938
+ elements.sessionMeta.classList.toggle("hidden", !showMeta);
939
+ elements.sessionMeta.hidden = !showMeta;
940
+ elements.disconnectButton.classList.toggle("hidden", !state.client);
941
+ elements.disconnectButton.hidden = !state.client;
942
+ elements.toggleInviteButton.classList.toggle("hidden", !state.client);
943
+ elements.toggleInviteButton.hidden = !state.client;
944
+
945
+ const shouldShowInviteForm = !state.client || state.showInviteForm;
946
+ elements.connectForm.classList.toggle("hidden", !shouldShowInviteForm);
947
+ elements.connectForm.hidden = !shouldShowInviteForm;
948
+
949
+ elements.providerLabel.textContent = state.invite?.providerLabel || t("chat.providerUnknown");
950
+ elements.expiresLabel.textContent = state.invite ? formatDateTime(state.invite.expiresAt) : t("chat.expiresUnknown");
951
+ if (state.invite?.groupMode) {
952
+ elements.modeLabel.textContent = state.botMuted ? t("chat.modeGroupMuted") : t("chat.modeGroup");
953
+ } else {
954
+ elements.modeLabel.textContent = t("chat.modePrivate");
955
+ }
956
+ elements.relayLabel.textContent = getInviteRelayLabel(state.invite) || t("chat.relayUnknown");
957
+ const identityValue = state.identity.displayName || `${t("chat.identityUnknown")} · ${state.identity.appId.slice(0, 8)}`;
958
+ elements.identityLabel.textContent = identityValue;
959
+ }
960
+
961
+ async function confirmRelayOverride(invite) {
962
+ if (!inviteUsesNonDefaultRelay(invite)) {
963
+ return true;
964
+ }
965
+ const relayLabel = getInviteRelayLabel(invite) || String(invite?.appWsUrl || "");
966
+ return window.confirm(
967
+ `${t("chat.customRelayWarningTitle")}\n\n${t("chat.customRelayWarningBody", {
968
+ relayLabel,
969
+ })}`,
970
+ );
971
+ }
972
+
973
+ function renderStatus() {
974
+ elements.statusPill.textContent = getStatusLabel(state.status);
975
+ elements.statusPill.dataset.status = state.status;
976
+ elements.statusCopy.textContent = state.statusCopy || t("chat.statusIdle");
977
+ }
978
+
979
+ function renderDesktopNote() {
980
+ const showWarning = !isMobileDevice();
981
+ elements.desktopNote.classList.toggle("hidden", !showWarning);
982
+ elements.desktopNote.hidden = !showWarning;
983
+ }
984
+
985
+ function renderPage() {
986
+ applyTranslations();
987
+ document.title = t("chat.documentTitle");
988
+ elements.inviteInput.placeholder = t("chat.inviteInputPlaceholder");
989
+ elements.composerInput.placeholder = t("chat.composerPlaceholder");
990
+ elements.attachButton.setAttribute("aria-label", t("chat.attachButtonAria"));
991
+ elements.commandButton.setAttribute("aria-label", t("chat.commandButtonAria"));
992
+ elements.closeCommandSheet.textContent = t("chat.commandSheetClose");
993
+ if (state.scanner.active) {
994
+ updateScannerStatus(t("chat.scannerStatusScanning"));
995
+ } else if (elements.scannerSheet.hidden) {
996
+ updateScannerStatus(t("chat.scannerStatusStarting"));
997
+ }
998
+ renderStatus();
999
+ renderDesktopNote();
1000
+ renderSessionMeta();
1001
+ renderParticipants();
1002
+ renderMessages();
1003
+ renderDraftAttachments();
1004
+ renderCommands();
1005
+ autoGrowComposer();
1006
+
1007
+ const canSend = state.status === "active" && Boolean(state.client);
1008
+ elements.sendButton.disabled = !canSend;
1009
+ elements.attachButton.disabled = !canSend;
1010
+ elements.commandButton.disabled = !canSend || state.commands.length === 0;
1011
+ elements.connectButton.disabled = state.status === "connecting" || state.status === "relayAttached";
1012
+ }
1013
+
1014
+ function openCommandSheet() {
1015
+ elements.commandSheet.classList.remove("hidden");
1016
+ elements.commandSheet.hidden = false;
1017
+ }
1018
+
1019
+ function closeCommandSheet() {
1020
+ elements.commandSheet.classList.add("hidden");
1021
+ elements.commandSheet.hidden = true;
1022
+ }
1023
+
1024
+ async function selectCommand(command) {
1025
+ if (!command) {
1026
+ return;
1027
+ }
1028
+ if (command.acceptsArgs) {
1029
+ elements.composerInput.value = `${command.slash} `;
1030
+ autoGrowComposer();
1031
+ elements.composerInput.focus();
1032
+ closeCommandSheet();
1033
+ showToast(t("chat.toastCommandInserted"));
1034
+ return;
1035
+ }
1036
+ elements.composerInput.value = command.slash;
1037
+ closeCommandSheet();
1038
+ await handleSend();
1039
+ showToast(t("chat.toastCommandSent"));
1040
+ }
1041
+
1042
+ function attachClientListeners(client) {
1043
+ client.addEventListener("state", (event) => {
1044
+ const previousStatus = state.status;
1045
+ const detail = event.detail;
1046
+ if (detail.invite) {
1047
+ state.invite = detail.invite;
1048
+ }
1049
+ if (detail.status) {
1050
+ setStatus(detail.status, { notice: detail.notice, details: detail.details });
1051
+ if (!detail.notice && detail.status === "active" && previousStatus !== "active") {
1052
+ state.statusCopy = t("chat.welcomeFallback");
1053
+ }
1054
+ if (detail.status === "closed") {
1055
+ state.client = null;
1056
+ state.showInviteForm = true;
1057
+ }
1058
+ }
1059
+ if (detail.notice === "relayError" || detail.notice === "connectionError" || detail.notice === "sessionClosed") {
1060
+ showToast(state.statusCopy, { error: detail.notice !== "sessionClosed" });
1061
+ }
1062
+ renderPage();
1063
+ });
1064
+
1065
+ client.addEventListener("message", (event) => {
1066
+ upsertMessage(event.detail.message);
1067
+ renderPage();
1068
+ });
1069
+
1070
+ client.addEventListener("capabilities", (event) => {
1071
+ const detail = event.detail;
1072
+ const firstConnection = state.status !== "active";
1073
+ state.invite = detail.invite;
1074
+ state.commands = detail.commands;
1075
+ state.participants = detail.participants;
1076
+ state.botMuted = detail.botMuted;
1077
+ state.status = detail.status || "active";
1078
+ state.statusCopy = t("chat.welcomeFallback");
1079
+ state.showInviteForm = false;
1080
+ if (detail.identity) {
1081
+ state.identity = detail.identity;
1082
+ saveIdentity(detail.identity);
1083
+ }
1084
+ if (firstConnection) {
1085
+ showToast(t("chat.toastConnected"));
1086
+ }
1087
+ renderPage();
1088
+ });
1089
+
1090
+ client.addEventListener("renewed", (event) => {
1091
+ const detail = event.detail;
1092
+ state.invite = detail.invite;
1093
+ state.status = "active";
1094
+ state.statusCopy = detail.message || t("chat.sessionRenewedNotice", { time: formatDateTime(detail.expiresAt) });
1095
+ upsertMessage({
1096
+ id: `renewed-${Date.now()}`,
1097
+ sender: "system",
1098
+ text: state.statusCopy,
1099
+ sentAt: new Date(),
1100
+ replyTo: detail.replyTo,
1101
+ severity: "info",
1102
+ attachments: [],
1103
+ });
1104
+ renderPage();
1105
+ });
1106
+ }
1107
+
1108
+ async function connectWithInvite(rawInvite) {
1109
+ let invite;
1110
+ try {
1111
+ invite = decodeInviteString(rawInvite);
1112
+ } catch (error) {
1113
+ showToast(mapClientError(error), { error: true });
1114
+ return;
1115
+ }
1116
+
1117
+ if (!(await confirmRelayOverride(invite))) {
1118
+ return;
1119
+ }
1120
+
1121
+ if (state.client) {
1122
+ await state.client.disconnect({ reason: "switch_invite", notifyRemote: false });
1123
+ }
1124
+
1125
+ resetConversationState();
1126
+ state.invite = invite;
1127
+ state.showInviteForm = true;
1128
+ state.client = new PrivateClawWebSessionClient(invite, { identity: state.identity });
1129
+ attachClientListeners(state.client);
1130
+ setStatus("connecting", { notice: "connectingRelay" });
1131
+ renderPage();
1132
+ showToast(t("chat.toastInviteReady"));
1133
+
1134
+ try {
1135
+ await state.client.connect();
1136
+ } catch (error) {
1137
+ state.client = null;
1138
+ setStatus("error", { notice: "connectionError", details: mapClientError(error) });
1139
+ showToast(mapClientError(error), { error: true });
1140
+ renderPage();
1141
+ }
1142
+ }
1143
+
1144
+ async function handleDisconnect() {
1145
+ if (state.client) {
1146
+ await state.client.disconnect({ reason: "user_disconnect" });
1147
+ }
1148
+ state.client = null;
1149
+ state.invite = null;
1150
+ resetConversationState();
1151
+ state.showInviteForm = true;
1152
+ state.status = "idle";
1153
+ state.statusCopy = t("chat.sessionDisconnected");
1154
+ renderPage();
1155
+ showToast(t("chat.toastDisconnected"));
1156
+ }
1157
+
1158
+ async function handleSend() {
1159
+ if (!state.client || state.status !== "active") {
1160
+ showToast(t("chat.notConnected"), { error: true });
1161
+ return;
1162
+ }
1163
+
1164
+ const text = elements.composerInput.value.trim();
1165
+ const attachments = [...state.selectedAttachments];
1166
+ if (!text && attachments.length === 0) {
1167
+ showToast(t("chat.toastCopiedNothing"));
1168
+ return;
1169
+ }
1170
+
1171
+ elements.composerInput.value = "";
1172
+ state.selectedAttachments = [];
1173
+ renderPage();
1174
+
1175
+ try {
1176
+ await state.client.sendUserMessage(text, { attachments });
1177
+ } catch (error) {
1178
+ elements.composerInput.value = text;
1179
+ state.selectedAttachments = attachments;
1180
+ state.status = "error";
1181
+ state.statusCopy = t("chat.sendFailed", { reason: mapClientError(error) });
1182
+ showToast(state.statusCopy, { error: true });
1183
+ renderPage();
1184
+ return;
1185
+ }
1186
+
1187
+ renderPage();
1188
+ }
1189
+
1190
+ async function handleFiles(files) {
1191
+ const nextAttachments = [];
1192
+ for (const file of files) {
1193
+ if (file.size > MAX_INLINE_ATTACHMENT_BYTES) {
1194
+ showToast(t("chat.fileTooLarge", { name: file.name }), { error: true });
1195
+ continue;
1196
+ }
1197
+ try {
1198
+ nextAttachments.push(await readFileAsAttachment(file));
1199
+ } catch (error) {
1200
+ console.warn("PrivateClaw could not read an attachment.", error);
1201
+ showToast(t("chat.fileReadError", { name: file.name }), { error: true });
1202
+ }
1203
+ }
1204
+ if (nextAttachments.length > 0) {
1205
+ state.selectedAttachments = [...state.selectedAttachments, ...nextAttachments];
1206
+ renderPage();
1207
+ }
1208
+ }
1209
+
1210
+ elements.connectForm.addEventListener("submit", async (event) => {
1211
+ event.preventDefault();
1212
+ await connectWithInvite(elements.inviteInput.value);
1213
+ });
1214
+
1215
+ elements.disconnectButton.addEventListener("click", async () => {
1216
+ await handleDisconnect();
1217
+ });
1218
+
1219
+ elements.toggleInviteButton.addEventListener("click", () => {
1220
+ state.showInviteForm = !state.showInviteForm;
1221
+ renderPage();
1222
+ });
1223
+
1224
+ elements.scanButton.addEventListener("click", async () => {
1225
+ await startScanner();
1226
+ });
1227
+
1228
+ elements.scanImageButton.addEventListener("click", () => {
1229
+ void openScanImagePicker();
1230
+ });
1231
+
1232
+ elements.inviteScanInput.addEventListener("change", async () => {
1233
+ const [file] = elements.inviteScanInput.files || [];
1234
+ if (file) {
1235
+ await scanFromImageFile(file);
1236
+ }
1237
+ elements.inviteScanInput.value = "";
1238
+ });
1239
+
1240
+ elements.attachButton.addEventListener("click", () => {
1241
+ if (!state.client || state.status !== "active") {
1242
+ showToast(t("chat.notConnected"), { error: true });
1243
+ return;
1244
+ }
1245
+ elements.fileInput.click();
1246
+ });
1247
+
1248
+ elements.fileInput.addEventListener("change", async () => {
1249
+ if (elements.fileInput.files) {
1250
+ await handleFiles([...elements.fileInput.files]);
1251
+ }
1252
+ elements.fileInput.value = "";
1253
+ });
1254
+
1255
+ elements.commandButton.addEventListener("click", () => {
1256
+ if (state.commands.length === 0) {
1257
+ showToast(t("chat.noCommandsYet"));
1258
+ return;
1259
+ }
1260
+ openCommandSheet();
1261
+ });
1262
+
1263
+ elements.closeCommandSheet.addEventListener("click", closeCommandSheet);
1264
+ elements.commandSheet.addEventListener("click", (event) => {
1265
+ if (event.target === elements.commandSheet) {
1266
+ closeCommandSheet();
1267
+ }
1268
+ });
1269
+
1270
+ elements.closeScannerSheet.addEventListener("click", async () => {
1271
+ await closeScannerSheet();
1272
+ });
1273
+ elements.scannerSheet.addEventListener("click", async (event) => {
1274
+ if (event.target === elements.scannerSheet) {
1275
+ await closeScannerSheet();
1276
+ }
1277
+ });
1278
+ elements.scannerUploadButton.addEventListener("click", () => {
1279
+ void openScanImagePicker();
1280
+ });
1281
+
1282
+ elements.composerForm.addEventListener("submit", async (event) => {
1283
+ event.preventDefault();
1284
+ await handleSend();
1285
+ });
1286
+
1287
+ elements.composerInput.addEventListener("input", autoGrowComposer);
1288
+ elements.composerInput.addEventListener("keydown", async (event) => {
1289
+ if (event.key === "Enter" && !event.shiftKey) {
1290
+ event.preventDefault();
1291
+ await handleSend();
1292
+ }
1293
+ });
1294
+
1295
+ window.addEventListener("resize", renderPage);
1296
+ window.addEventListener("beforeunload", () => {
1297
+ clearObjectUrls();
1298
+ void stopScanner();
1299
+ });
1300
+ onLocaleChange(renderPage);
1301
+
1302
+ setStatus("idle");
1303
+ renderPage();