livechat 0.5.1 → 0.5.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/app/controllers/livechat/attachments_controller.rb +4 -4
- data/app/helpers/livechat/inbox_helper.rb +5 -2
- data/app/models/livechat/message.rb +5 -3
- data/app/views/layouts/livechat/application.html.erb +10 -0
- data/app/views/livechat/conversations/show.html.erb +10 -1
- data/config/locales/livechat.ar.yml +14 -0
- data/config/locales/livechat.bg.yml +14 -0
- data/config/locales/livechat.bn.yml +14 -0
- data/config/locales/livechat.de.yml +14 -0
- data/config/locales/livechat.el.yml +14 -0
- data/config/locales/livechat.en.yml +14 -0
- data/config/locales/livechat.es.yml +14 -0
- data/config/locales/livechat.fr.yml +14 -0
- data/config/locales/livechat.hi.yml +14 -0
- data/config/locales/livechat.hr.yml +14 -0
- data/config/locales/livechat.id.yml +14 -0
- data/config/locales/livechat.it.yml +14 -0
- data/config/locales/livechat.ja.yml +14 -0
- data/config/locales/livechat.ko.yml +14 -0
- data/config/locales/livechat.lb.yml +14 -0
- data/config/locales/livechat.nl.yml +14 -0
- data/config/locales/livechat.pl.yml +14 -0
- data/config/locales/livechat.pt.yml +14 -0
- data/config/locales/livechat.ro.yml +14 -0
- data/config/locales/livechat.ru.yml +14 -0
- data/config/locales/livechat.th.yml +14 -0
- data/config/locales/livechat.tr.yml +14 -0
- data/config/locales/livechat.uk.yml +14 -0
- data/config/locales/livechat.ur.yml +14 -0
- data/config/locales/livechat.vi.yml +14 -0
- data/config/locales/livechat.zh-CN.yml +14 -0
- data/lib/livechat/dashboard.js +185 -2
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +261 -18
- data/lib/livechat/widget.rb +8 -0
- metadata +1 -1
data/lib/livechat/widget.js
CHANGED
|
@@ -34,9 +34,18 @@
|
|
|
34
34
|
var errorEl = null;
|
|
35
35
|
var fileInputEl = null;
|
|
36
36
|
var filesBarEl = null;
|
|
37
|
+
var recordBtnEl = null;
|
|
38
|
+
var recordingBarEl = null;
|
|
39
|
+
var recordingTimeEl = null;
|
|
37
40
|
// Files the visitor has picked but not yet sent. Sent as multipart; kept
|
|
38
41
|
// out of the JSON path so a text-only message stays a plain JSON POST.
|
|
39
42
|
var pendingFiles = [];
|
|
43
|
+
var mediaRecorder = null;
|
|
44
|
+
var recordingStream = null;
|
|
45
|
+
var recordingChunks = [];
|
|
46
|
+
var recordingStartedAt = 0;
|
|
47
|
+
var recordingTimer = null;
|
|
48
|
+
var recordingCancelled = false;
|
|
40
49
|
var isOpen = false;
|
|
41
50
|
var lastFocused = null;
|
|
42
51
|
var expandBtnEl = null;
|
|
@@ -47,7 +56,9 @@
|
|
|
47
56
|
// locked); on desktop it stays a non-modal popover you can chat alongside.
|
|
48
57
|
var mobileModal = window.matchMedia ? window.matchMedia("(max-width: 480px)") : null;
|
|
49
58
|
var savedBodyStyle = null; // body's style while the mobile scroll lock is on
|
|
59
|
+
var savedHtmlStyle = null;
|
|
50
60
|
var savedScrollY = 0;
|
|
61
|
+
var lastTouchY = 0;
|
|
51
62
|
// The id of the newest message actually RENDERED into the list — never
|
|
52
63
|
// advanced by background polls, so reopening the panel refetches exactly
|
|
53
64
|
// the messages it hasn't shown yet.
|
|
@@ -324,6 +335,20 @@
|
|
|
324
335
|
attach.addEventListener("click", function () { fileInputEl.click(); });
|
|
325
336
|
tools.appendChild(attach);
|
|
326
337
|
formEl.appendChild(fileInputEl);
|
|
338
|
+
|
|
339
|
+
recordBtnEl = document.createElement("button");
|
|
340
|
+
recordBtnEl.type = "button";
|
|
341
|
+
recordBtnEl.id = "lvc-record";
|
|
342
|
+
recordBtnEl.className = "lvc-tool";
|
|
343
|
+
recordBtnEl.setAttribute("aria-label", config.labels.recordAudio);
|
|
344
|
+
recordBtnEl.title = config.labels.recordAudio;
|
|
345
|
+
recordBtnEl.innerHTML =
|
|
346
|
+
'<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">' +
|
|
347
|
+
'<path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
|
|
348
|
+
'stroke-linejoin="round" d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z' +
|
|
349
|
+
'M5 11a7 7 0 0 0 14 0M12 18v3M8.5 21h7"/></svg>';
|
|
350
|
+
recordBtnEl.addEventListener("click", startRecording);
|
|
351
|
+
tools.appendChild(recordBtnEl);
|
|
327
352
|
}
|
|
328
353
|
toolbar.appendChild(tools);
|
|
329
354
|
|
|
@@ -342,6 +367,10 @@
|
|
|
342
367
|
toolbar.appendChild(sendButton);
|
|
343
368
|
|
|
344
369
|
formEl.appendChild(toolbar);
|
|
370
|
+
if (config.attachments) {
|
|
371
|
+
recordingBarEl = buildRecordingBar();
|
|
372
|
+
formEl.appendChild(recordingBarEl);
|
|
373
|
+
}
|
|
345
374
|
formEl.addEventListener("submit", function (event) {
|
|
346
375
|
event.preventDefault();
|
|
347
376
|
send();
|
|
@@ -352,6 +381,162 @@
|
|
|
352
381
|
return panel;
|
|
353
382
|
}
|
|
354
383
|
|
|
384
|
+
// --- audio recording --------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
function buildRecordingBar() {
|
|
387
|
+
var bar = document.createElement("div");
|
|
388
|
+
bar.id = "lvc-recording";
|
|
389
|
+
bar.hidden = true;
|
|
390
|
+
|
|
391
|
+
var status = document.createElement("span");
|
|
392
|
+
status.className = "lvc-recording-status";
|
|
393
|
+
var dot = document.createElement("span");
|
|
394
|
+
dot.className = "lvc-recording-dot";
|
|
395
|
+
recordingTimeEl = document.createElement("span");
|
|
396
|
+
recordingTimeEl.textContent = formatDuration(0);
|
|
397
|
+
status.appendChild(dot);
|
|
398
|
+
status.appendChild(recordingTimeEl);
|
|
399
|
+
bar.appendChild(status);
|
|
400
|
+
|
|
401
|
+
var actions = document.createElement("div");
|
|
402
|
+
actions.className = "lvc-recording-actions";
|
|
403
|
+
var cancel = document.createElement("button");
|
|
404
|
+
cancel.type = "button";
|
|
405
|
+
cancel.className = "lvc-recording-cancel";
|
|
406
|
+
cancel.textContent = config.labels.cancelRecording;
|
|
407
|
+
cancel.addEventListener("click", cancelRecording);
|
|
408
|
+
var stop = document.createElement("button");
|
|
409
|
+
stop.type = "button";
|
|
410
|
+
stop.className = "lvc-recording-stop";
|
|
411
|
+
stop.textContent = config.labels.stopRecording;
|
|
412
|
+
stop.addEventListener("click", stopRecording);
|
|
413
|
+
actions.appendChild(cancel);
|
|
414
|
+
actions.appendChild(stop);
|
|
415
|
+
bar.appendChild(actions);
|
|
416
|
+
return bar;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function recordingSupported() {
|
|
420
|
+
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function startRecording() {
|
|
424
|
+
if (mediaRecorder) return;
|
|
425
|
+
if (!recordingSupported()) {
|
|
426
|
+
showError(config.labels.microphoneUnsupported);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (pendingFiles.length >= (config.maxAttachments || 5)) {
|
|
430
|
+
showError(config.labels.attachmentError);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
errorEl.hidden = true;
|
|
435
|
+
if (recordBtnEl) recordBtnEl.disabled = true;
|
|
436
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
437
|
+
.then(function (stream) {
|
|
438
|
+
var options = preferredAudioOptions();
|
|
439
|
+
recordingChunks = [];
|
|
440
|
+
recordingCancelled = false;
|
|
441
|
+
recordingStream = stream;
|
|
442
|
+
try {
|
|
443
|
+
mediaRecorder = options ? new MediaRecorder(stream, options) : new MediaRecorder(stream);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
stopRecordingTracks();
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
mediaRecorder.addEventListener("dataavailable", function (event) {
|
|
449
|
+
if (event.data && event.data.size) recordingChunks.push(event.data);
|
|
450
|
+
});
|
|
451
|
+
mediaRecorder.addEventListener("stop", finishRecording);
|
|
452
|
+
mediaRecorder.start();
|
|
453
|
+
recordingStartedAt = Date.now();
|
|
454
|
+
showRecordingBar(true);
|
|
455
|
+
tickRecording();
|
|
456
|
+
recordingTimer = setInterval(tickRecording, 500);
|
|
457
|
+
})
|
|
458
|
+
.catch(function (error) {
|
|
459
|
+
if (recordBtnEl) recordBtnEl.disabled = false;
|
|
460
|
+
var denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError");
|
|
461
|
+
showError(denied ? config.labels.microphoneDenied : config.labels.microphoneError);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function preferredAudioOptions() {
|
|
466
|
+
var types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
467
|
+
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return null;
|
|
468
|
+
for (var i = 0; i < types.length; i++) {
|
|
469
|
+
if (MediaRecorder.isTypeSupported(types[i])) return { mimeType: types[i] };
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function stopRecording() {
|
|
475
|
+
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function cancelRecording() {
|
|
479
|
+
recordingCancelled = true;
|
|
480
|
+
stopRecording();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function finishRecording() {
|
|
484
|
+
var mimeType = (mediaRecorder && mediaRecorder.mimeType) || "audio/webm";
|
|
485
|
+
stopRecordingTracks();
|
|
486
|
+
clearInterval(recordingTimer);
|
|
487
|
+
recordingTimer = null;
|
|
488
|
+
showRecordingBar(false);
|
|
489
|
+
if (recordBtnEl) recordBtnEl.disabled = false;
|
|
490
|
+
|
|
491
|
+
if (!recordingCancelled && recordingChunks.length) {
|
|
492
|
+
pendingFiles.push(audioFile(recordingChunks, mimeType));
|
|
493
|
+
renderFiles();
|
|
494
|
+
}
|
|
495
|
+
mediaRecorder = null;
|
|
496
|
+
recordingChunks = [];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function stopRecordingTracks() {
|
|
500
|
+
if (!recordingStream) return;
|
|
501
|
+
recordingStream.getTracks().forEach(function (track) { track.stop(); });
|
|
502
|
+
recordingStream = null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function showRecordingBar(show) {
|
|
506
|
+
if (recordingBarEl) recordingBarEl.hidden = !show;
|
|
507
|
+
if (recordBtnEl) recordBtnEl.classList.toggle("lvc-recording-on", show);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function tickRecording() {
|
|
511
|
+
if (!recordingTimeEl) return;
|
|
512
|
+
recordingTimeEl.textContent = config.labels.recording.replace("%{time}", formatDuration(Date.now() - recordingStartedAt));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function formatDuration(ms) {
|
|
516
|
+
var seconds = Math.max(0, Math.floor(ms / 1000));
|
|
517
|
+
var minutes = Math.floor(seconds / 60);
|
|
518
|
+
seconds = seconds % 60;
|
|
519
|
+
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function audioFile(chunks, mimeType) {
|
|
523
|
+
var blob = new Blob(chunks, { type: mimeType });
|
|
524
|
+
var extension = audioExtension(mimeType);
|
|
525
|
+
var name = "voice-message-" + new Date().toISOString().replace(/[:.]/g, "-") + "." + extension;
|
|
526
|
+
try {
|
|
527
|
+
return new File([blob], name, { type: mimeType });
|
|
528
|
+
} catch (e) {
|
|
529
|
+
blob.name = name;
|
|
530
|
+
return blob;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function audioExtension(mimeType) {
|
|
535
|
+
if (/mp4|mpeg|m4a/.test(mimeType)) return "m4a";
|
|
536
|
+
if (/ogg/.test(mimeType)) return "ogg";
|
|
537
|
+
return "webm";
|
|
538
|
+
}
|
|
539
|
+
|
|
355
540
|
// --- pending attachments ----------------------------------------------------
|
|
356
541
|
|
|
357
542
|
function addFiles(fileList) {
|
|
@@ -381,7 +566,7 @@
|
|
|
381
566
|
chip.className = "lvc-chip";
|
|
382
567
|
var name = document.createElement("span");
|
|
383
568
|
name.className = "lvc-chip-name";
|
|
384
|
-
name.textContent = file.name;
|
|
569
|
+
name.textContent = file.name || "voice-message.webm";
|
|
385
570
|
var remove = document.createElement("button");
|
|
386
571
|
remove.type = "button";
|
|
387
572
|
remove.className = "lvc-chip-x";
|
|
@@ -537,34 +722,69 @@
|
|
|
537
722
|
}
|
|
538
723
|
}
|
|
539
724
|
|
|
540
|
-
// A real iOS scroll lock. `overflow:hidden`
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
// panel covers the visible area exactly). We snapshot the body's style and
|
|
546
|
-
// scroll position and restore both on unlock. Mobile modal only; the desktop
|
|
547
|
-
// popover never locks.
|
|
725
|
+
// A real iOS scroll lock. `overflow:hidden` alone is ignored by Safari for
|
|
726
|
+
// touch scrolling, so the page can slide behind the full-screen panel while
|
|
727
|
+
// the keyboard changes the visual viewport. Pinning <body>, hiding overflow
|
|
728
|
+
// on both root elements, and cancelling touch scroll that escapes the message
|
|
729
|
+
// list keeps the host page frozen while the modal remains scrollable.
|
|
548
730
|
function lockScroll() {
|
|
549
731
|
if (savedBodyStyle !== null) return;
|
|
550
732
|
savedScrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
|
551
733
|
savedBodyStyle = document.body.getAttribute("style") || "";
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
734
|
+
savedHtmlStyle = document.documentElement.getAttribute("style") || "";
|
|
735
|
+
|
|
736
|
+
var html = document.documentElement.style;
|
|
737
|
+
html.overflow = "hidden";
|
|
738
|
+
html.overscrollBehavior = "none";
|
|
739
|
+
|
|
740
|
+
var body = document.body.style;
|
|
741
|
+
body.position = "fixed";
|
|
742
|
+
body.top = -savedScrollY + "px";
|
|
743
|
+
body.left = "0";
|
|
744
|
+
body.right = "0";
|
|
745
|
+
body.width = "100%";
|
|
746
|
+
body.overflow = "hidden";
|
|
747
|
+
body.overscrollBehavior = "none";
|
|
748
|
+
|
|
749
|
+
document.addEventListener("touchstart", rememberTouchY, { passive: true });
|
|
750
|
+
document.addEventListener("touchmove", preventBackgroundTouchMove, { passive: false });
|
|
559
751
|
}
|
|
560
752
|
|
|
561
753
|
function unlockScroll() {
|
|
562
754
|
if (savedBodyStyle === null) return;
|
|
755
|
+
document.removeEventListener("touchstart", rememberTouchY);
|
|
756
|
+
document.removeEventListener("touchmove", preventBackgroundTouchMove);
|
|
757
|
+
document.documentElement.setAttribute("style", savedHtmlStyle || "");
|
|
563
758
|
document.body.setAttribute("style", savedBodyStyle);
|
|
564
759
|
savedBodyStyle = null;
|
|
760
|
+
savedHtmlStyle = null;
|
|
565
761
|
window.scrollTo(0, savedScrollY);
|
|
566
762
|
}
|
|
567
763
|
|
|
764
|
+
function rememberTouchY(event) {
|
|
765
|
+
if (event.touches && event.touches.length) lastTouchY = event.touches[0].clientY;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function preventBackgroundTouchMove(event) {
|
|
769
|
+
if (!isOpen || !isMobileModal()) return;
|
|
770
|
+
var target = event.target;
|
|
771
|
+
if (target && target.closest && target.closest("#lvc-input")) return;
|
|
772
|
+
|
|
773
|
+
var scroller = target && target.closest ? target.closest("#lvc-list") : null;
|
|
774
|
+
if (!scroller) {
|
|
775
|
+
event.preventDefault();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
var touchY = event.touches && event.touches.length ? event.touches[0].clientY : lastTouchY;
|
|
780
|
+
var movingDown = touchY > lastTouchY;
|
|
781
|
+
var atTop = scroller.scrollTop <= 0;
|
|
782
|
+
var atBottom = Math.ceil(scroller.scrollTop + scroller.clientHeight) >= scroller.scrollHeight;
|
|
783
|
+
lastTouchY = touchY;
|
|
784
|
+
|
|
785
|
+
if ((movingDown && atTop) || (!movingDown && atBottom)) event.preventDefault();
|
|
786
|
+
}
|
|
787
|
+
|
|
568
788
|
// --- mobile keyboard: keep the full-screen panel above the keyboard ---------
|
|
569
789
|
|
|
570
790
|
// On phones the panel is position:fixed at height:100dvh. When the composer
|
|
@@ -580,11 +800,13 @@
|
|
|
580
800
|
var panel = document.getElementById("lvc-panel");
|
|
581
801
|
if (!panel || !window.visualViewport) return;
|
|
582
802
|
if (isMobileModal()) {
|
|
803
|
+
if (isOpen) lockScroll();
|
|
583
804
|
panel.style.height = window.visualViewport.height + "px";
|
|
584
805
|
panel.style.top = window.visualViewport.offsetTop + "px";
|
|
585
806
|
scrollToBottom(); // newest message stays in view above the keyboard
|
|
586
807
|
} else {
|
|
587
808
|
// Not the mobile modal — hand height/top back to the stylesheet.
|
|
809
|
+
unlockScroll();
|
|
588
810
|
panel.style.height = "";
|
|
589
811
|
panel.style.top = "";
|
|
590
812
|
}
|
|
@@ -747,6 +969,15 @@
|
|
|
747
969
|
// re-pin to the bottom so a just-arrived image stays in view.
|
|
748
970
|
img.addEventListener("load", scrollToBottom);
|
|
749
971
|
link.appendChild(img);
|
|
972
|
+
} else if (att.audio) {
|
|
973
|
+
var audio = document.createElement("audio");
|
|
974
|
+
audio.className = "lvc-att-audio";
|
|
975
|
+
audio.controls = true;
|
|
976
|
+
audio.preload = "metadata";
|
|
977
|
+
audio.src = att.url;
|
|
978
|
+
audio.setAttribute("aria-label", att.name);
|
|
979
|
+
wrap.appendChild(audio);
|
|
980
|
+
return;
|
|
750
981
|
} else {
|
|
751
982
|
link.className = "lvc-att-file";
|
|
752
983
|
link.textContent = att.name;
|
|
@@ -805,7 +1036,7 @@
|
|
|
805
1036
|
|
|
806
1037
|
function send() {
|
|
807
1038
|
var body = (inputEl.value || "").trim();
|
|
808
|
-
if ((!body && !pendingFiles.length) || sending) return;
|
|
1039
|
+
if (mediaRecorder || (!body && !pendingFiles.length) || sending) return;
|
|
809
1040
|
sending = true;
|
|
810
1041
|
errorEl.hidden = true;
|
|
811
1042
|
var button = formEl.querySelector("button[type=submit]");
|
|
@@ -849,7 +1080,7 @@
|
|
|
849
1080
|
form.append("body", body);
|
|
850
1081
|
form.append("page_url", window.location.href);
|
|
851
1082
|
form.append("locale", config.locale);
|
|
852
|
-
pendingFiles.forEach(function (file) { form.append("files[]", file); });
|
|
1083
|
+
pendingFiles.forEach(function (file) { form.append("files[]", file, file.name || "voice-message.webm"); });
|
|
853
1084
|
return form;
|
|
854
1085
|
}
|
|
855
1086
|
|
|
@@ -983,6 +1214,7 @@
|
|
|
983
1214
|
"#lvc-root .lvc-tool{border:none;background:none;color:var(--lvc-muted);width:32px;height:32px;" +
|
|
984
1215
|
"display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:8px;padding:0}" +
|
|
985
1216
|
"#lvc-root .lvc-tool:hover{background:var(--lvc-border);color:var(--lvc-text)}" +
|
|
1217
|
+
"#lvc-root #lvc-record.lvc-recording-on{color:#dc2626}" +
|
|
986
1218
|
"#lvc-send{border:none;border-radius:50%;background:var(--lvc-accent);color:var(--lvc-accent-text);" +
|
|
987
1219
|
"width:32px;height:32px;min-width:32px;display:flex;align-items:center;justify-content:center;" +
|
|
988
1220
|
"cursor:pointer;padding:0}" +
|
|
@@ -998,10 +1230,21 @@
|
|
|
998
1230
|
"#lvc-root .lvc-chip-x{border:none;background:none;color:var(--lvc-muted);font-size:16px;" +
|
|
999
1231
|
"line-height:1;cursor:pointer;padding:0 2px}" +
|
|
1000
1232
|
"#lvc-root .lvc-chip-x:hover{color:var(--lvc-text)}" +
|
|
1233
|
+
"#lvc-recording{display:flex;align-items:center;justify-content:space-between;gap:8px;" +
|
|
1234
|
+
"padding:6px 4px 0;color:var(--lvc-muted);font-size:12px}" +
|
|
1235
|
+
"#lvc-recording[hidden]{display:none}" +
|
|
1236
|
+
"#lvc-root .lvc-recording-status{display:inline-flex;align-items:center;gap:6px}" +
|
|
1237
|
+
"#lvc-root .lvc-recording-dot{width:8px;height:8px;border-radius:50%;background:#dc2626}" +
|
|
1238
|
+
"#lvc-root .lvc-recording-actions{display:flex;align-items:center;gap:4px}" +
|
|
1239
|
+
"#lvc-root .lvc-recording-actions button{border:1px solid var(--lvc-border);border-radius:8px;" +
|
|
1240
|
+
"background:var(--lvc-surface);color:var(--lvc-text);font:inherit;font-size:12px;line-height:1;" +
|
|
1241
|
+
"padding:6px 8px;cursor:pointer}" +
|
|
1242
|
+
"#lvc-root .lvc-recording-stop{border-color:#dc2626;color:#dc2626}" +
|
|
1001
1243
|
// Attachments inside a message bubble: thumbnails and file links.
|
|
1002
1244
|
"#lvc-root .lvc-atts{display:flex;flex-direction:column;gap:6px;margin-top:6px}" +
|
|
1003
1245
|
"#lvc-root .lvc-att-img{display:block}" +
|
|
1004
1246
|
"#lvc-root .lvc-att-img img{max-width:100%;max-height:220px;border-radius:8px;display:block}" +
|
|
1247
|
+
"#lvc-root .lvc-att-audio{display:block;width:240px;max-width:100%;height:36px}" +
|
|
1005
1248
|
"#lvc-root .lvc-att-file{display:inline-block;padding:6px 10px;border-radius:8px;" +
|
|
1006
1249
|
"background:rgba(0,0,0,.06);color:inherit;text-decoration:none;font-size:13px;" +
|
|
1007
1250
|
"overflow-wrap:anywhere}" +
|
data/lib/livechat/widget.rb
CHANGED
|
@@ -103,6 +103,14 @@ module Livechat
|
|
|
103
103
|
errorSend: t(:error_send, 'Could not send. Please try again.'),
|
|
104
104
|
unreadAria: t(:unread_aria, 'unread messages'),
|
|
105
105
|
attach: t(:attach, 'Attach files'),
|
|
106
|
+
recordAudio: t(:record_audio, 'Record audio'),
|
|
107
|
+
stopRecording: t(:stop_recording, 'Stop recording'),
|
|
108
|
+
cancelRecording: t(:cancel_recording, 'Cancel recording'),
|
|
109
|
+
recording: t(:recording, 'Recording %{time}'),
|
|
110
|
+
microphoneUnsupported: t(:microphone_unsupported, 'Audio recording is not supported in this browser.'),
|
|
111
|
+
microphoneDenied: t(:microphone_denied,
|
|
112
|
+
'Microphone access was blocked. Allow microphone access in your browser and try again.'),
|
|
113
|
+
microphoneError: t(:microphone_error, 'Could not start audio recording.'),
|
|
106
114
|
removeFile: t(:remove_file, 'Remove file'),
|
|
107
115
|
attachmentError: t(:attachment_error, 'Some files could not be sent.'),
|
|
108
116
|
expand: t(:expand, 'Expand'),
|