livechat 0.5.1 → 0.5.3
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 +17 -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 +20 -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 +206 -2
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +288 -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,23 @@
|
|
|
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);
|
|
352
|
+
|
|
353
|
+
recordingBarEl = buildRecordingBar();
|
|
354
|
+
tools.appendChild(recordingBarEl);
|
|
327
355
|
}
|
|
328
356
|
toolbar.appendChild(tools);
|
|
329
357
|
|
|
@@ -352,6 +380,182 @@
|
|
|
352
380
|
return panel;
|
|
353
381
|
}
|
|
354
382
|
|
|
383
|
+
// --- audio recording --------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
function buildRecordingBar() {
|
|
386
|
+
var bar = document.createElement("div");
|
|
387
|
+
bar.id = "lvc-recording";
|
|
388
|
+
bar.hidden = true;
|
|
389
|
+
|
|
390
|
+
var status = document.createElement("span");
|
|
391
|
+
status.className = "lvc-recording-status";
|
|
392
|
+
status.setAttribute("aria-live", "polite");
|
|
393
|
+
var dot = document.createElement("span");
|
|
394
|
+
dot.className = "lvc-recording-dot";
|
|
395
|
+
var rec = document.createElement("span");
|
|
396
|
+
rec.className = "lvc-recording-rec";
|
|
397
|
+
rec.textContent = "REC";
|
|
398
|
+
recordingTimeEl = document.createElement("span");
|
|
399
|
+
recordingTimeEl.textContent = formatDuration(0);
|
|
400
|
+
status.appendChild(dot);
|
|
401
|
+
status.appendChild(rec);
|
|
402
|
+
status.appendChild(recordingTimeEl);
|
|
403
|
+
bar.appendChild(status);
|
|
404
|
+
|
|
405
|
+
var actions = document.createElement("div");
|
|
406
|
+
actions.className = "lvc-recording-actions";
|
|
407
|
+
var cancel = document.createElement("button");
|
|
408
|
+
cancel.type = "button";
|
|
409
|
+
cancel.className = "lvc-recording-cancel";
|
|
410
|
+
cancel.setAttribute("aria-label", config.labels.cancelRecording);
|
|
411
|
+
cancel.title = config.labels.cancelRecording;
|
|
412
|
+
cancel.innerHTML =
|
|
413
|
+
'<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">' +
|
|
414
|
+
'<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
|
|
415
|
+
'd="M6 6l12 12M18 6 6 18"/></svg>';
|
|
416
|
+
cancel.addEventListener("click", cancelRecording);
|
|
417
|
+
var stop = document.createElement("button");
|
|
418
|
+
stop.type = "button";
|
|
419
|
+
stop.className = "lvc-recording-stop";
|
|
420
|
+
stop.setAttribute("aria-label", config.labels.stopRecording);
|
|
421
|
+
stop.title = config.labels.stopRecording;
|
|
422
|
+
stop.innerHTML =
|
|
423
|
+
'<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">' +
|
|
424
|
+
'<rect x="7" y="7" width="10" height="10" rx="1.5" fill="currentColor"/></svg>';
|
|
425
|
+
stop.addEventListener("click", stopRecording);
|
|
426
|
+
actions.appendChild(cancel);
|
|
427
|
+
actions.appendChild(stop);
|
|
428
|
+
bar.appendChild(actions);
|
|
429
|
+
return bar;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function recordingSupported() {
|
|
433
|
+
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function startRecording() {
|
|
437
|
+
if (mediaRecorder) return;
|
|
438
|
+
if (!recordingSupported()) {
|
|
439
|
+
showError(config.labels.microphoneUnsupported);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (pendingFiles.length >= (config.maxAttachments || 5)) {
|
|
443
|
+
showError(config.labels.attachmentError);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
errorEl.hidden = true;
|
|
448
|
+
if (recordBtnEl) recordBtnEl.disabled = true;
|
|
449
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
450
|
+
.then(function (stream) {
|
|
451
|
+
var options = preferredAudioOptions();
|
|
452
|
+
recordingChunks = [];
|
|
453
|
+
recordingCancelled = false;
|
|
454
|
+
recordingStream = stream;
|
|
455
|
+
try {
|
|
456
|
+
mediaRecorder = options ? new MediaRecorder(stream, options) : new MediaRecorder(stream);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
stopRecordingTracks();
|
|
459
|
+
throw e;
|
|
460
|
+
}
|
|
461
|
+
mediaRecorder.addEventListener("dataavailable", function (event) {
|
|
462
|
+
if (event.data && event.data.size) recordingChunks.push(event.data);
|
|
463
|
+
});
|
|
464
|
+
mediaRecorder.addEventListener("stop", finishRecording);
|
|
465
|
+
mediaRecorder.start();
|
|
466
|
+
recordingStartedAt = Date.now();
|
|
467
|
+
showRecordingBar(true);
|
|
468
|
+
tickRecording();
|
|
469
|
+
recordingTimer = setInterval(tickRecording, 500);
|
|
470
|
+
})
|
|
471
|
+
.catch(function (error) {
|
|
472
|
+
if (recordBtnEl) recordBtnEl.disabled = false;
|
|
473
|
+
var denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError");
|
|
474
|
+
showError(denied ? config.labels.microphoneDenied : config.labels.microphoneError);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function preferredAudioOptions() {
|
|
479
|
+
var types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
480
|
+
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return null;
|
|
481
|
+
for (var i = 0; i < types.length; i++) {
|
|
482
|
+
if (MediaRecorder.isTypeSupported(types[i])) return { mimeType: types[i] };
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function stopRecording() {
|
|
488
|
+
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function cancelRecording() {
|
|
492
|
+
recordingCancelled = true;
|
|
493
|
+
stopRecording();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function finishRecording() {
|
|
497
|
+
var mimeType = (mediaRecorder && mediaRecorder.mimeType) || "audio/webm";
|
|
498
|
+
stopRecordingTracks();
|
|
499
|
+
clearInterval(recordingTimer);
|
|
500
|
+
recordingTimer = null;
|
|
501
|
+
showRecordingBar(false);
|
|
502
|
+
if (recordBtnEl) recordBtnEl.disabled = false;
|
|
503
|
+
|
|
504
|
+
if (!recordingCancelled && recordingChunks.length) {
|
|
505
|
+
pendingFiles.push(audioFile(recordingChunks, mimeType));
|
|
506
|
+
renderFiles();
|
|
507
|
+
}
|
|
508
|
+
mediaRecorder = null;
|
|
509
|
+
recordingChunks = [];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function stopRecordingTracks() {
|
|
513
|
+
if (!recordingStream) return;
|
|
514
|
+
recordingStream.getTracks().forEach(function (track) { track.stop(); });
|
|
515
|
+
recordingStream = null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function showRecordingBar(show) {
|
|
519
|
+
if (recordingBarEl) recordingBarEl.hidden = !show;
|
|
520
|
+
if (recordBtnEl) recordBtnEl.classList.toggle("lvc-recording-on", show);
|
|
521
|
+
if (formEl) formEl.classList.toggle("lvc-is-recording", show);
|
|
522
|
+
var sendButton = formEl && formEl.querySelector("button[type=submit]");
|
|
523
|
+
if (sendButton) sendButton.disabled = show;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function tickRecording() {
|
|
527
|
+
if (!recordingTimeEl) return;
|
|
528
|
+
var elapsed = formatDuration(Date.now() - recordingStartedAt);
|
|
529
|
+
recordingTimeEl.textContent = elapsed;
|
|
530
|
+
var status = recordingTimeEl.closest(".lvc-recording-status");
|
|
531
|
+
if (status) status.setAttribute("aria-label", config.labels.recording.replace("%{time}", elapsed));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function formatDuration(ms) {
|
|
535
|
+
var seconds = Math.max(0, Math.floor(ms / 1000));
|
|
536
|
+
var minutes = Math.floor(seconds / 60);
|
|
537
|
+
seconds = seconds % 60;
|
|
538
|
+
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function audioFile(chunks, mimeType) {
|
|
542
|
+
var blob = new Blob(chunks, { type: mimeType });
|
|
543
|
+
var extension = audioExtension(mimeType);
|
|
544
|
+
var name = "voice-message-" + new Date().toISOString().replace(/[:.]/g, "-") + "." + extension;
|
|
545
|
+
try {
|
|
546
|
+
return new File([blob], name, { type: mimeType });
|
|
547
|
+
} catch (e) {
|
|
548
|
+
blob.name = name;
|
|
549
|
+
return blob;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function audioExtension(mimeType) {
|
|
554
|
+
if (/mp4|mpeg|m4a/.test(mimeType)) return "m4a";
|
|
555
|
+
if (/ogg/.test(mimeType)) return "ogg";
|
|
556
|
+
return "webm";
|
|
557
|
+
}
|
|
558
|
+
|
|
355
559
|
// --- pending attachments ----------------------------------------------------
|
|
356
560
|
|
|
357
561
|
function addFiles(fileList) {
|
|
@@ -381,7 +585,7 @@
|
|
|
381
585
|
chip.className = "lvc-chip";
|
|
382
586
|
var name = document.createElement("span");
|
|
383
587
|
name.className = "lvc-chip-name";
|
|
384
|
-
name.textContent = file.name;
|
|
588
|
+
name.textContent = file.name || "voice-message.webm";
|
|
385
589
|
var remove = document.createElement("button");
|
|
386
590
|
remove.type = "button";
|
|
387
591
|
remove.className = "lvc-chip-x";
|
|
@@ -537,34 +741,69 @@
|
|
|
537
741
|
}
|
|
538
742
|
}
|
|
539
743
|
|
|
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.
|
|
744
|
+
// A real iOS scroll lock. `overflow:hidden` alone is ignored by Safari for
|
|
745
|
+
// touch scrolling, so the page can slide behind the full-screen panel while
|
|
746
|
+
// the keyboard changes the visual viewport. Pinning <body>, hiding overflow
|
|
747
|
+
// on both root elements, and cancelling touch scroll that escapes the message
|
|
748
|
+
// list keeps the host page frozen while the modal remains scrollable.
|
|
548
749
|
function lockScroll() {
|
|
549
750
|
if (savedBodyStyle !== null) return;
|
|
550
751
|
savedScrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
|
551
752
|
savedBodyStyle = document.body.getAttribute("style") || "";
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
753
|
+
savedHtmlStyle = document.documentElement.getAttribute("style") || "";
|
|
754
|
+
|
|
755
|
+
var html = document.documentElement.style;
|
|
756
|
+
html.overflow = "hidden";
|
|
757
|
+
html.overscrollBehavior = "none";
|
|
758
|
+
|
|
759
|
+
var body = document.body.style;
|
|
760
|
+
body.position = "fixed";
|
|
761
|
+
body.top = -savedScrollY + "px";
|
|
762
|
+
body.left = "0";
|
|
763
|
+
body.right = "0";
|
|
764
|
+
body.width = "100%";
|
|
765
|
+
body.overflow = "hidden";
|
|
766
|
+
body.overscrollBehavior = "none";
|
|
767
|
+
|
|
768
|
+
document.addEventListener("touchstart", rememberTouchY, { passive: true });
|
|
769
|
+
document.addEventListener("touchmove", preventBackgroundTouchMove, { passive: false });
|
|
559
770
|
}
|
|
560
771
|
|
|
561
772
|
function unlockScroll() {
|
|
562
773
|
if (savedBodyStyle === null) return;
|
|
774
|
+
document.removeEventListener("touchstart", rememberTouchY);
|
|
775
|
+
document.removeEventListener("touchmove", preventBackgroundTouchMove);
|
|
776
|
+
document.documentElement.setAttribute("style", savedHtmlStyle || "");
|
|
563
777
|
document.body.setAttribute("style", savedBodyStyle);
|
|
564
778
|
savedBodyStyle = null;
|
|
779
|
+
savedHtmlStyle = null;
|
|
565
780
|
window.scrollTo(0, savedScrollY);
|
|
566
781
|
}
|
|
567
782
|
|
|
783
|
+
function rememberTouchY(event) {
|
|
784
|
+
if (event.touches && event.touches.length) lastTouchY = event.touches[0].clientY;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function preventBackgroundTouchMove(event) {
|
|
788
|
+
if (!isOpen || !isMobileModal()) return;
|
|
789
|
+
var target = event.target;
|
|
790
|
+
if (target && target.closest && target.closest("#lvc-input")) return;
|
|
791
|
+
|
|
792
|
+
var scroller = target && target.closest ? target.closest("#lvc-list") : null;
|
|
793
|
+
if (!scroller) {
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
var touchY = event.touches && event.touches.length ? event.touches[0].clientY : lastTouchY;
|
|
799
|
+
var movingDown = touchY > lastTouchY;
|
|
800
|
+
var atTop = scroller.scrollTop <= 0;
|
|
801
|
+
var atBottom = Math.ceil(scroller.scrollTop + scroller.clientHeight) >= scroller.scrollHeight;
|
|
802
|
+
lastTouchY = touchY;
|
|
803
|
+
|
|
804
|
+
if ((movingDown && atTop) || (!movingDown && atBottom)) event.preventDefault();
|
|
805
|
+
}
|
|
806
|
+
|
|
568
807
|
// --- mobile keyboard: keep the full-screen panel above the keyboard ---------
|
|
569
808
|
|
|
570
809
|
// On phones the panel is position:fixed at height:100dvh. When the composer
|
|
@@ -580,11 +819,13 @@
|
|
|
580
819
|
var panel = document.getElementById("lvc-panel");
|
|
581
820
|
if (!panel || !window.visualViewport) return;
|
|
582
821
|
if (isMobileModal()) {
|
|
822
|
+
if (isOpen) lockScroll();
|
|
583
823
|
panel.style.height = window.visualViewport.height + "px";
|
|
584
824
|
panel.style.top = window.visualViewport.offsetTop + "px";
|
|
585
825
|
scrollToBottom(); // newest message stays in view above the keyboard
|
|
586
826
|
} else {
|
|
587
827
|
// Not the mobile modal — hand height/top back to the stylesheet.
|
|
828
|
+
unlockScroll();
|
|
588
829
|
panel.style.height = "";
|
|
589
830
|
panel.style.top = "";
|
|
590
831
|
}
|
|
@@ -747,6 +988,15 @@
|
|
|
747
988
|
// re-pin to the bottom so a just-arrived image stays in view.
|
|
748
989
|
img.addEventListener("load", scrollToBottom);
|
|
749
990
|
link.appendChild(img);
|
|
991
|
+
} else if (att.audio) {
|
|
992
|
+
var audio = document.createElement("audio");
|
|
993
|
+
audio.className = "lvc-att-audio";
|
|
994
|
+
audio.controls = true;
|
|
995
|
+
audio.preload = "metadata";
|
|
996
|
+
audio.src = att.url;
|
|
997
|
+
audio.setAttribute("aria-label", att.name);
|
|
998
|
+
wrap.appendChild(audio);
|
|
999
|
+
return;
|
|
750
1000
|
} else {
|
|
751
1001
|
link.className = "lvc-att-file";
|
|
752
1002
|
link.textContent = att.name;
|
|
@@ -805,7 +1055,7 @@
|
|
|
805
1055
|
|
|
806
1056
|
function send() {
|
|
807
1057
|
var body = (inputEl.value || "").trim();
|
|
808
|
-
if ((!body && !pendingFiles.length) || sending) return;
|
|
1058
|
+
if (mediaRecorder || (!body && !pendingFiles.length) || sending) return;
|
|
809
1059
|
sending = true;
|
|
810
1060
|
errorEl.hidden = true;
|
|
811
1061
|
var button = formEl.querySelector("button[type=submit]");
|
|
@@ -849,7 +1099,7 @@
|
|
|
849
1099
|
form.append("body", body);
|
|
850
1100
|
form.append("page_url", window.location.href);
|
|
851
1101
|
form.append("locale", config.locale);
|
|
852
|
-
pendingFiles.forEach(function (file) { form.append("files[]", file); });
|
|
1102
|
+
pendingFiles.forEach(function (file) { form.append("files[]", file, file.name || "voice-message.webm"); });
|
|
853
1103
|
return form;
|
|
854
1104
|
}
|
|
855
1105
|
|
|
@@ -983,6 +1233,9 @@
|
|
|
983
1233
|
"#lvc-root .lvc-tool{border:none;background:none;color:var(--lvc-muted);width:32px;height:32px;" +
|
|
984
1234
|
"display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:8px;padding:0}" +
|
|
985
1235
|
"#lvc-root .lvc-tool:hover{background:var(--lvc-border);color:var(--lvc-text)}" +
|
|
1236
|
+
"#lvc-root #lvc-record.lvc-recording-on{color:#dc2626}" +
|
|
1237
|
+
"#lvc-root #lvc-form.lvc-is-recording #lvc-attach,#lvc-root #lvc-form.lvc-is-recording #lvc-record" +
|
|
1238
|
+
"{display:none}" +
|
|
986
1239
|
"#lvc-send{border:none;border-radius:50%;background:var(--lvc-accent);color:var(--lvc-accent-text);" +
|
|
987
1240
|
"width:32px;height:32px;min-width:32px;display:flex;align-items:center;justify-content:center;" +
|
|
988
1241
|
"cursor:pointer;padding:0}" +
|
|
@@ -998,10 +1251,27 @@
|
|
|
998
1251
|
"#lvc-root .lvc-chip-x{border:none;background:none;color:var(--lvc-muted);font-size:16px;" +
|
|
999
1252
|
"line-height:1;cursor:pointer;padding:0 2px}" +
|
|
1000
1253
|
"#lvc-root .lvc-chip-x:hover{color:var(--lvc-text)}" +
|
|
1254
|
+
"#lvc-recording{display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 4px 0 8px;" +
|
|
1255
|
+
"border:1px solid var(--lvc-border);border-radius:999px;background:var(--lvc-surface);" +
|
|
1256
|
+
"color:var(--lvc-muted);font-size:12px;white-space:nowrap}" +
|
|
1257
|
+
"#lvc-recording[hidden]{display:none}" +
|
|
1258
|
+
"#lvc-root .lvc-recording-status{display:inline-grid;grid-template-columns:8px 24px 34px;" +
|
|
1259
|
+
"align-items:center;gap:6px;font-variant-numeric:tabular-nums}" +
|
|
1260
|
+
"#lvc-root .lvc-recording-dot{width:8px;height:8px;border-radius:50%;background:#dc2626;" +
|
|
1261
|
+
"box-shadow:0 0 0 3px rgba(220,38,38,.12)}" +
|
|
1262
|
+
"#lvc-root .lvc-recording-rec{font-size:10px;font-weight:700;letter-spacing:.04em;color:#dc2626}" +
|
|
1263
|
+
"#lvc-root .lvc-recording-actions{display:flex;align-items:center;gap:4px}" +
|
|
1264
|
+
"#lvc-root .lvc-recording-actions button{width:24px;height:24px;border:none;border-radius:50%;" +
|
|
1265
|
+
"background:none;color:var(--lvc-muted);display:flex;align-items:center;justify-content:center;" +
|
|
1266
|
+
"padding:0;cursor:pointer}" +
|
|
1267
|
+
"#lvc-root .lvc-recording-actions button:hover{background:var(--lvc-border);color:var(--lvc-text)}" +
|
|
1268
|
+
"#lvc-root .lvc-recording-stop{background:#dc2626;color:#fff}" +
|
|
1269
|
+
"#lvc-root .lvc-recording-stop:hover{background:#b91c1c;color:#fff}" +
|
|
1001
1270
|
// Attachments inside a message bubble: thumbnails and file links.
|
|
1002
1271
|
"#lvc-root .lvc-atts{display:flex;flex-direction:column;gap:6px;margin-top:6px}" +
|
|
1003
1272
|
"#lvc-root .lvc-att-img{display:block}" +
|
|
1004
1273
|
"#lvc-root .lvc-att-img img{max-width:100%;max-height:220px;border-radius:8px;display:block}" +
|
|
1274
|
+
"#lvc-root .lvc-att-audio{display:block;width:240px;max-width:100%;height:36px}" +
|
|
1005
1275
|
"#lvc-root .lvc-att-file{display:inline-block;padding:6px 10px;border-radius:8px;" +
|
|
1006
1276
|
"background:rgba(0,0,0,.06);color:inherit;text-decoration:none;font-size:13px;" +
|
|
1007
1277
|
"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'),
|