livechat 0.5.0 → 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 +18 -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 +270 -10
- 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;
|
|
@@ -46,7 +55,10 @@
|
|
|
46
55
|
// On phones the panel is a full-screen modal (focus trapped, page scroll
|
|
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
|
-
var
|
|
58
|
+
var savedBodyStyle = null; // body's style while the mobile scroll lock is on
|
|
59
|
+
var savedHtmlStyle = null;
|
|
60
|
+
var savedScrollY = 0;
|
|
61
|
+
var lastTouchY = 0;
|
|
50
62
|
// The id of the newest message actually RENDERED into the list — never
|
|
51
63
|
// advanced by background polls, so reopening the panel refetches exactly
|
|
52
64
|
// the messages it hasn't shown yet.
|
|
@@ -323,6 +335,20 @@
|
|
|
323
335
|
attach.addEventListener("click", function () { fileInputEl.click(); });
|
|
324
336
|
tools.appendChild(attach);
|
|
325
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);
|
|
326
352
|
}
|
|
327
353
|
toolbar.appendChild(tools);
|
|
328
354
|
|
|
@@ -341,6 +367,10 @@
|
|
|
341
367
|
toolbar.appendChild(sendButton);
|
|
342
368
|
|
|
343
369
|
formEl.appendChild(toolbar);
|
|
370
|
+
if (config.attachments) {
|
|
371
|
+
recordingBarEl = buildRecordingBar();
|
|
372
|
+
formEl.appendChild(recordingBarEl);
|
|
373
|
+
}
|
|
344
374
|
formEl.addEventListener("submit", function (event) {
|
|
345
375
|
event.preventDefault();
|
|
346
376
|
send();
|
|
@@ -351,6 +381,162 @@
|
|
|
351
381
|
return panel;
|
|
352
382
|
}
|
|
353
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
|
+
|
|
354
540
|
// --- pending attachments ----------------------------------------------------
|
|
355
541
|
|
|
356
542
|
function addFiles(fileList) {
|
|
@@ -380,7 +566,7 @@
|
|
|
380
566
|
chip.className = "lvc-chip";
|
|
381
567
|
var name = document.createElement("span");
|
|
382
568
|
name.className = "lvc-chip-name";
|
|
383
|
-
name.textContent = file.name;
|
|
569
|
+
name.textContent = file.name || "voice-message.webm";
|
|
384
570
|
var remove = document.createElement("button");
|
|
385
571
|
remove.type = "button";
|
|
386
572
|
remove.className = "lvc-chip-x";
|
|
@@ -536,16 +722,67 @@
|
|
|
536
722
|
}
|
|
537
723
|
}
|
|
538
724
|
|
|
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.
|
|
539
730
|
function lockScroll() {
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
document.
|
|
731
|
+
if (savedBodyStyle !== null) return;
|
|
732
|
+
savedScrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
|
733
|
+
savedBodyStyle = document.body.getAttribute("style") || "";
|
|
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 });
|
|
543
751
|
}
|
|
544
752
|
|
|
545
753
|
function unlockScroll() {
|
|
546
|
-
if (
|
|
547
|
-
document.
|
|
548
|
-
|
|
754
|
+
if (savedBodyStyle === null) return;
|
|
755
|
+
document.removeEventListener("touchstart", rememberTouchY);
|
|
756
|
+
document.removeEventListener("touchmove", preventBackgroundTouchMove);
|
|
757
|
+
document.documentElement.setAttribute("style", savedHtmlStyle || "");
|
|
758
|
+
document.body.setAttribute("style", savedBodyStyle);
|
|
759
|
+
savedBodyStyle = null;
|
|
760
|
+
savedHtmlStyle = null;
|
|
761
|
+
window.scrollTo(0, savedScrollY);
|
|
762
|
+
}
|
|
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();
|
|
549
786
|
}
|
|
550
787
|
|
|
551
788
|
// --- mobile keyboard: keep the full-screen panel above the keyboard ---------
|
|
@@ -563,11 +800,13 @@
|
|
|
563
800
|
var panel = document.getElementById("lvc-panel");
|
|
564
801
|
if (!panel || !window.visualViewport) return;
|
|
565
802
|
if (isMobileModal()) {
|
|
803
|
+
if (isOpen) lockScroll();
|
|
566
804
|
panel.style.height = window.visualViewport.height + "px";
|
|
567
805
|
panel.style.top = window.visualViewport.offsetTop + "px";
|
|
568
806
|
scrollToBottom(); // newest message stays in view above the keyboard
|
|
569
807
|
} else {
|
|
570
808
|
// Not the mobile modal — hand height/top back to the stylesheet.
|
|
809
|
+
unlockScroll();
|
|
571
810
|
panel.style.height = "";
|
|
572
811
|
panel.style.top = "";
|
|
573
812
|
}
|
|
@@ -730,6 +969,15 @@
|
|
|
730
969
|
// re-pin to the bottom so a just-arrived image stays in view.
|
|
731
970
|
img.addEventListener("load", scrollToBottom);
|
|
732
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;
|
|
733
981
|
} else {
|
|
734
982
|
link.className = "lvc-att-file";
|
|
735
983
|
link.textContent = att.name;
|
|
@@ -788,7 +1036,7 @@
|
|
|
788
1036
|
|
|
789
1037
|
function send() {
|
|
790
1038
|
var body = (inputEl.value || "").trim();
|
|
791
|
-
if ((!body && !pendingFiles.length) || sending) return;
|
|
1039
|
+
if (mediaRecorder || (!body && !pendingFiles.length) || sending) return;
|
|
792
1040
|
sending = true;
|
|
793
1041
|
errorEl.hidden = true;
|
|
794
1042
|
var button = formEl.querySelector("button[type=submit]");
|
|
@@ -832,7 +1080,7 @@
|
|
|
832
1080
|
form.append("body", body);
|
|
833
1081
|
form.append("page_url", window.location.href);
|
|
834
1082
|
form.append("locale", config.locale);
|
|
835
|
-
pendingFiles.forEach(function (file) { form.append("files[]", file); });
|
|
1083
|
+
pendingFiles.forEach(function (file) { form.append("files[]", file, file.name || "voice-message.webm"); });
|
|
836
1084
|
return form;
|
|
837
1085
|
}
|
|
838
1086
|
|
|
@@ -966,6 +1214,7 @@
|
|
|
966
1214
|
"#lvc-root .lvc-tool{border:none;background:none;color:var(--lvc-muted);width:32px;height:32px;" +
|
|
967
1215
|
"display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:8px;padding:0}" +
|
|
968
1216
|
"#lvc-root .lvc-tool:hover{background:var(--lvc-border);color:var(--lvc-text)}" +
|
|
1217
|
+
"#lvc-root #lvc-record.lvc-recording-on{color:#dc2626}" +
|
|
969
1218
|
"#lvc-send{border:none;border-radius:50%;background:var(--lvc-accent);color:var(--lvc-accent-text);" +
|
|
970
1219
|
"width:32px;height:32px;min-width:32px;display:flex;align-items:center;justify-content:center;" +
|
|
971
1220
|
"cursor:pointer;padding:0}" +
|
|
@@ -981,10 +1230,21 @@
|
|
|
981
1230
|
"#lvc-root .lvc-chip-x{border:none;background:none;color:var(--lvc-muted);font-size:16px;" +
|
|
982
1231
|
"line-height:1;cursor:pointer;padding:0 2px}" +
|
|
983
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}" +
|
|
984
1243
|
// Attachments inside a message bubble: thumbnails and file links.
|
|
985
1244
|
"#lvc-root .lvc-atts{display:flex;flex-direction:column;gap:6px;margin-top:6px}" +
|
|
986
1245
|
"#lvc-root .lvc-att-img{display:block}" +
|
|
987
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}" +
|
|
988
1248
|
"#lvc-root .lvc-att-file{display:inline-block;padding:6px 10px;border-radius:8px;" +
|
|
989
1249
|
"background:rgba(0,0,0,.06);color:inherit;text-decoration:none;font-size:13px;" +
|
|
990
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'),
|