livechat 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +47 -1
- data/app/controllers/livechat/attachments_controller.rb +61 -0
- data/app/controllers/livechat/conversations_controller.rb +1 -17
- data/app/controllers/livechat/messages_controller.rb +2 -1
- data/app/controllers/livechat/visitor_controller.rb +13 -2
- data/app/helpers/livechat/inbox_helper.rb +32 -0
- data/app/models/livechat/conversation.rb +22 -6
- data/app/models/livechat/message.rb +110 -2
- data/app/views/layouts/livechat/application.html.erb +9 -1
- data/app/views/livechat/conversations/index.html.erb +3 -1
- data/app/views/livechat/conversations/show.html.erb +14 -5
- data/config/locales/livechat.ar.yml +8 -0
- data/config/locales/livechat.bg.yml +8 -0
- data/config/locales/livechat.bn.yml +8 -0
- data/config/locales/livechat.de.yml +8 -0
- data/config/locales/livechat.el.yml +8 -0
- data/config/locales/livechat.en.yml +8 -0
- data/config/locales/livechat.es.yml +8 -0
- data/config/locales/livechat.fr.yml +8 -0
- data/config/locales/livechat.hi.yml +8 -0
- data/config/locales/livechat.hr.yml +8 -0
- data/config/locales/livechat.id.yml +8 -0
- data/config/locales/livechat.it.yml +8 -0
- data/config/locales/livechat.ja.yml +8 -0
- data/config/locales/livechat.ko.yml +8 -0
- data/config/locales/livechat.lb.yml +8 -0
- data/config/locales/livechat.nl.yml +8 -0
- data/config/locales/livechat.pl.yml +8 -0
- data/config/locales/livechat.pt.yml +8 -0
- data/config/locales/livechat.ro.yml +8 -0
- data/config/locales/livechat.ru.yml +8 -0
- data/config/locales/livechat.th.yml +8 -0
- data/config/locales/livechat.tr.yml +8 -0
- data/config/locales/livechat.uk.yml +8 -0
- data/config/locales/livechat.ur.yml +8 -0
- data/config/locales/livechat.vi.yml +8 -0
- data/config/locales/livechat.zh-CN.yml +8 -0
- data/config/routes.rb +6 -0
- data/lib/generators/livechat/install/templates/initializer.rb +15 -0
- data/lib/livechat/channels.rb +27 -0
- data/lib/livechat/configuration.rb +28 -0
- data/lib/livechat/dashboard.js +73 -5
- data/lib/livechat/engine.rb +7 -0
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +210 -10
- data/lib/livechat/widget.rb +6 -1
- data/lib/livechat.rb +31 -0
- metadata +3 -1
data/lib/livechat/dashboard.js
CHANGED
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
var url = index.getAttribute("data-poll-url");
|
|
37
37
|
var searchBox = document.querySelector(".filters input[type=search]");
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
function check() {
|
|
40
40
|
if (document.hidden) return;
|
|
41
|
+
// Never reload out from under an agent who is typing a search.
|
|
41
42
|
if (searchBox && searchBox.value !== searchBox.defaultValue) return;
|
|
42
43
|
if (searchBox && document.activeElement === searchBox) return;
|
|
43
44
|
fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
@@ -46,7 +47,12 @@
|
|
|
46
47
|
if (data && data.token !== token) window.location.reload();
|
|
47
48
|
})
|
|
48
49
|
.catch(function () { /* transient network error — next tick retries */ });
|
|
49
|
-
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setInterval(check, POLL_MS);
|
|
53
|
+
// Action Cable (opt-in): a nudge runs the check at once, so the list
|
|
54
|
+
// refreshes the instant something changes. Polling stays the fallback.
|
|
55
|
+
openCable(index, check);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
function watchThread() {
|
|
@@ -97,11 +103,39 @@
|
|
|
97
103
|
var bubble = document.createElement("div");
|
|
98
104
|
bubble.className = "msg " + message.author;
|
|
99
105
|
bubble.id = "message-" + message.id;
|
|
100
|
-
bubble.
|
|
106
|
+
if (message.body) bubble.appendChild(document.createTextNode(message.body));
|
|
107
|
+
appendAttachments(bubble, message.attachments);
|
|
101
108
|
thread.appendChild(bubble);
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
// Mirrors the server-rendered thread: images inline, other files as links,
|
|
112
|
+
// all pointing at the engine's gated attachment route.
|
|
113
|
+
function appendAttachments(bubble, attachments) {
|
|
114
|
+
if (!attachments || !attachments.length) return;
|
|
115
|
+
var wrap = document.createElement("div");
|
|
116
|
+
wrap.className = "atts";
|
|
117
|
+
attachments.forEach(function (att) {
|
|
118
|
+
var link = document.createElement("a");
|
|
119
|
+
link.href = att.url;
|
|
120
|
+
link.target = "_blank";
|
|
121
|
+
link.rel = "noopener";
|
|
122
|
+
if (att.image) {
|
|
123
|
+
link.className = "att-img";
|
|
124
|
+
var img = document.createElement("img");
|
|
125
|
+
img.src = att.url;
|
|
126
|
+
img.alt = att.name;
|
|
127
|
+
img.loading = "lazy";
|
|
128
|
+
link.appendChild(img);
|
|
129
|
+
} else {
|
|
130
|
+
link.className = "att-file";
|
|
131
|
+
link.textContent = att.name;
|
|
132
|
+
}
|
|
133
|
+
wrap.appendChild(link);
|
|
134
|
+
});
|
|
135
|
+
bubble.appendChild(wrap);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function refresh() {
|
|
105
139
|
if (document.hidden) return;
|
|
106
140
|
// Only auto-scroll if the agent is already reading the latest — don't
|
|
107
141
|
// yank them down while they've scrolled up through history.
|
|
@@ -114,6 +148,40 @@
|
|
|
114
148
|
if (atBottom) thread.scrollTop = thread.scrollHeight;
|
|
115
149
|
})
|
|
116
150
|
.catch(function () { /* transient network error — next tick retries */ });
|
|
117
|
-
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setInterval(refresh, POLL_MS);
|
|
154
|
+
// Action Cable (opt-in): a nudge fetches new messages at once instead of
|
|
155
|
+
// waiting for the next tick. Polling stays the fallback.
|
|
156
|
+
openCable(thread, refresh);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A tiny Action Cable client over the native WebSocket protocol — no
|
|
160
|
+
// @rails/actioncable dependency, no build step. It opens only when the
|
|
161
|
+
// element carries a signed stream (push turned on); otherwise polling alone
|
|
162
|
+
// carries the page. The broadcast is a nudge with no payload: on any data
|
|
163
|
+
// message we just run onNudge, which refetches through the gated endpoint.
|
|
164
|
+
function openCable(el, onNudge) {
|
|
165
|
+
var path = el.getAttribute("data-cable-url");
|
|
166
|
+
var stream = el.getAttribute("data-cable-stream");
|
|
167
|
+
if (!path || !stream || !window.WebSocket) return;
|
|
168
|
+
|
|
169
|
+
var identifier = JSON.stringify({ channel: "Livechat::StreamChannel", signed_stream: stream });
|
|
170
|
+
var socket;
|
|
171
|
+
try {
|
|
172
|
+
var proto = location.protocol === "https:" ? "wss://" : "ws://";
|
|
173
|
+
socket = new WebSocket(proto + location.host + path, ["actioncable-v1-json"]);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return; // polling still covers the page
|
|
176
|
+
}
|
|
177
|
+
socket.onmessage = function (event) {
|
|
178
|
+
var data;
|
|
179
|
+
try { data = JSON.parse(event.data); } catch (e) { return; }
|
|
180
|
+
if (data.type === "welcome") {
|
|
181
|
+
socket.send(JSON.stringify({ command: "subscribe", identifier: identifier }));
|
|
182
|
+
} else if (data.message) {
|
|
183
|
+
onNudge();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
118
186
|
}
|
|
119
187
|
})();
|
data/lib/livechat/engine.rb
CHANGED
|
@@ -9,5 +9,12 @@ module Livechat
|
|
|
9
9
|
include Livechat::WidgetHelper
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
# The channel lives under lib/ (not app/channels), required only where
|
|
14
|
+
# Action Cable exists — so eager-load in an app without it never fails on
|
|
15
|
+
# a missing ActionCable::Channel::Base.
|
|
16
|
+
initializer 'livechat.action_cable' do
|
|
17
|
+
require 'livechat/channels' if defined?(ActionCable)
|
|
18
|
+
end
|
|
12
19
|
end
|
|
13
20
|
end
|
data/lib/livechat/version.rb
CHANGED
data/lib/livechat/widget.js
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
var inputEl = null;
|
|
33
33
|
var emailRowEl = null;
|
|
34
34
|
var errorEl = null;
|
|
35
|
+
var fileInputEl = null;
|
|
36
|
+
var filesBarEl = null;
|
|
37
|
+
// Files the visitor has picked but not yet sent. Sent as multipart; kept
|
|
38
|
+
// out of the JSON path so a text-only message stays a plain JSON POST.
|
|
39
|
+
var pendingFiles = [];
|
|
35
40
|
var isOpen = false;
|
|
36
41
|
var lastFocused = null;
|
|
37
42
|
// On phones the panel is a full-screen modal (focus trapped, page scroll
|
|
@@ -48,6 +53,13 @@
|
|
|
48
53
|
var sending = false;
|
|
49
54
|
var pollTimer = null;
|
|
50
55
|
var fetching = false;
|
|
56
|
+
// Action Cable (opt-in). The widget learns its signed stream from the poll
|
|
57
|
+
// response — a per-conversation token it never has to guess — and opens a
|
|
58
|
+
// socket that nudges it to poll the instant a reply lands. cableDisabled
|
|
59
|
+
// latches on a rejected subscription so we fall back to polling for good.
|
|
60
|
+
var cableSocket = null;
|
|
61
|
+
var cableStream = null;
|
|
62
|
+
var cableDisabled = false;
|
|
51
63
|
|
|
52
64
|
function ready(fn) {
|
|
53
65
|
if (document.readyState === "loading") {
|
|
@@ -263,11 +275,87 @@
|
|
|
263
275
|
event.preventDefault();
|
|
264
276
|
send();
|
|
265
277
|
});
|
|
278
|
+
|
|
279
|
+
// Attachments: a paperclip that opens the file picker, plus a bar of
|
|
280
|
+
// chips for the files waiting to be sent. Only when the host has them on.
|
|
281
|
+
if (config.attachments) {
|
|
282
|
+
filesBarEl = document.createElement("div");
|
|
283
|
+
filesBarEl.id = "lvc-files";
|
|
284
|
+
filesBarEl.hidden = true;
|
|
285
|
+
panel.appendChild(filesBarEl);
|
|
286
|
+
|
|
287
|
+
fileInputEl = document.createElement("input");
|
|
288
|
+
fileInputEl.type = "file";
|
|
289
|
+
fileInputEl.multiple = true;
|
|
290
|
+
fileInputEl.hidden = true;
|
|
291
|
+
fileInputEl.addEventListener("change", function () {
|
|
292
|
+
addFiles(fileInputEl.files);
|
|
293
|
+
fileInputEl.value = ""; // let the same file be re-picked after removal
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
var attach = document.createElement("button");
|
|
297
|
+
attach.type = "button";
|
|
298
|
+
attach.id = "lvc-attach";
|
|
299
|
+
attach.setAttribute("aria-label", config.labels.attach);
|
|
300
|
+
attach.title = config.labels.attach;
|
|
301
|
+
// Paperclip (Heroicons, MIT).
|
|
302
|
+
attach.innerHTML =
|
|
303
|
+
'<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">' +
|
|
304
|
+
'<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" ' +
|
|
305
|
+
'stroke-linejoin="round" d="M18.4 8.6 9.9 17a3.5 3.5 0 0 1-5-5l8.5-8.4a2.3 2.3 0 0 1 ' +
|
|
306
|
+
'3.3 3.3l-8.5 8.4a1.1 1.1 0 0 1-1.6-1.6l7.8-7.8"/></svg>';
|
|
307
|
+
attach.addEventListener("click", function () { fileInputEl.click(); });
|
|
308
|
+
formEl.insertBefore(attach, inputEl);
|
|
309
|
+
formEl.appendChild(fileInputEl);
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
panel.appendChild(formEl);
|
|
267
313
|
|
|
268
314
|
return panel;
|
|
269
315
|
}
|
|
270
316
|
|
|
317
|
+
// --- pending attachments ----------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function addFiles(fileList) {
|
|
320
|
+
var room = Math.max(0, (config.maxAttachments || 5) - pendingFiles.length);
|
|
321
|
+
for (var i = 0; i < fileList.length && i < room; i++) {
|
|
322
|
+
pendingFiles.push(fileList[i]);
|
|
323
|
+
}
|
|
324
|
+
renderFiles();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function removeFile(index) {
|
|
328
|
+
pendingFiles.splice(index, 1);
|
|
329
|
+
renderFiles();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function clearFiles() {
|
|
333
|
+
pendingFiles = [];
|
|
334
|
+
renderFiles();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderFiles() {
|
|
338
|
+
if (!filesBarEl) return;
|
|
339
|
+
filesBarEl.textContent = "";
|
|
340
|
+
filesBarEl.hidden = pendingFiles.length === 0;
|
|
341
|
+
pendingFiles.forEach(function (file, index) {
|
|
342
|
+
var chip = document.createElement("span");
|
|
343
|
+
chip.className = "lvc-chip";
|
|
344
|
+
var name = document.createElement("span");
|
|
345
|
+
name.className = "lvc-chip-name";
|
|
346
|
+
name.textContent = file.name;
|
|
347
|
+
var remove = document.createElement("button");
|
|
348
|
+
remove.type = "button";
|
|
349
|
+
remove.className = "lvc-chip-x";
|
|
350
|
+
remove.setAttribute("aria-label", config.labels.removeFile);
|
|
351
|
+
remove.innerHTML = "×";
|
|
352
|
+
remove.addEventListener("click", function () { removeFile(index); });
|
|
353
|
+
chip.appendChild(name);
|
|
354
|
+
chip.appendChild(remove);
|
|
355
|
+
filesBarEl.appendChild(chip);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
271
359
|
// Guests only: one quiet row under the thread asking for an email, shown
|
|
272
360
|
// once they have written and until they save one.
|
|
273
361
|
function buildEmailRow() {
|
|
@@ -415,6 +503,7 @@
|
|
|
415
503
|
|
|
416
504
|
hasThread = data.status !== null;
|
|
417
505
|
hasEmail = !!data.email;
|
|
506
|
+
if (data.cable) ensureCable(data.cable);
|
|
418
507
|
|
|
419
508
|
if (isOpen) {
|
|
420
509
|
// Only an open panel renders (and thereby "consumes") messages;
|
|
@@ -499,29 +588,96 @@
|
|
|
499
588
|
|
|
500
589
|
var bubble = document.createElement("div");
|
|
501
590
|
bubble.className = "lvc-msg lvc-" + message.author;
|
|
502
|
-
bubble.
|
|
591
|
+
if (message.body) bubble.appendChild(document.createTextNode(message.body));
|
|
592
|
+
renderAttachments(bubble, message.attachments);
|
|
503
593
|
bubble.title = new Date(message.at).toLocaleString();
|
|
504
594
|
listEl.appendChild(bubble);
|
|
505
595
|
scrollToBottom();
|
|
506
596
|
}
|
|
507
597
|
|
|
598
|
+
// Images show inline (a thumbnail linking to the full file); everything
|
|
599
|
+
// else is a labelled download link. Both hit the engine's gated route.
|
|
600
|
+
function renderAttachments(bubble, attachments) {
|
|
601
|
+
if (!attachments || !attachments.length) return;
|
|
602
|
+
var wrap = document.createElement("div");
|
|
603
|
+
wrap.className = "lvc-atts";
|
|
604
|
+
attachments.forEach(function (att) {
|
|
605
|
+
var link = document.createElement("a");
|
|
606
|
+
link.href = att.url;
|
|
607
|
+
link.target = "_blank";
|
|
608
|
+
link.rel = "noopener";
|
|
609
|
+
if (att.image) {
|
|
610
|
+
link.className = "lvc-att-img";
|
|
611
|
+
var img = document.createElement("img");
|
|
612
|
+
img.src = att.url;
|
|
613
|
+
img.alt = att.name;
|
|
614
|
+
img.loading = "lazy";
|
|
615
|
+
link.appendChild(img);
|
|
616
|
+
} else {
|
|
617
|
+
link.className = "lvc-att-file";
|
|
618
|
+
link.textContent = att.name;
|
|
619
|
+
}
|
|
620
|
+
wrap.appendChild(link);
|
|
621
|
+
});
|
|
622
|
+
bubble.appendChild(wrap);
|
|
623
|
+
}
|
|
624
|
+
|
|
508
625
|
function scrollToBottom() {
|
|
509
626
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
|
510
627
|
}
|
|
511
628
|
|
|
629
|
+
// --- realtime (optional) ----------------------------------------------------
|
|
630
|
+
|
|
631
|
+
// A tiny Action Cable client over the native WebSocket protocol — no
|
|
632
|
+
// @rails/actioncable dependency, no build step. Opens once per conversation
|
|
633
|
+
// stream; a data message is a nudge, so we just poll() through the normal
|
|
634
|
+
// gated path. Polling keeps running regardless, so this only ever makes the
|
|
635
|
+
// widget faster, never load-bearing.
|
|
636
|
+
function ensureCable(info) {
|
|
637
|
+
if (cableDisabled || !info.stream || !window.WebSocket) return;
|
|
638
|
+
if (cableStream === info.stream && cableSocket) return; // already connected
|
|
639
|
+
|
|
640
|
+
cableStream = info.stream;
|
|
641
|
+
var identifier = JSON.stringify({
|
|
642
|
+
channel: "Livechat::StreamChannel", signed_stream: info.stream
|
|
643
|
+
});
|
|
644
|
+
try {
|
|
645
|
+
if (cableSocket) cableSocket.close();
|
|
646
|
+
var proto = window.location.protocol === "https:" ? "wss://" : "ws://";
|
|
647
|
+
cableSocket = new WebSocket(proto + window.location.host + info.url, ["actioncable-v1-json"]);
|
|
648
|
+
} catch (e) {
|
|
649
|
+
cableSocket = null;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
cableSocket.onmessage = function (event) {
|
|
654
|
+
var data;
|
|
655
|
+
try { data = JSON.parse(event.data); } catch (e) { return; }
|
|
656
|
+
if (data.type === "welcome") {
|
|
657
|
+
cableSocket.send(JSON.stringify({ command: "subscribe", identifier: identifier }));
|
|
658
|
+
} else if (data.type === "reject_subscription") {
|
|
659
|
+
cableDisabled = true; // never retry; polling carries on
|
|
660
|
+
if (cableSocket) cableSocket.close();
|
|
661
|
+
} else if (data.message) {
|
|
662
|
+
poll();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
cableSocket.onclose = function () {
|
|
666
|
+
cableSocket = null;
|
|
667
|
+
// Allow a reconnect on the next poll unless the server rejected us.
|
|
668
|
+
if (!cableDisabled) cableStream = null;
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
512
672
|
function send() {
|
|
513
673
|
var body = (inputEl.value || "").trim();
|
|
514
|
-
if (!body || sending) return;
|
|
674
|
+
if ((!body && !pendingFiles.length) || sending) return;
|
|
515
675
|
sending = true;
|
|
516
676
|
errorEl.hidden = true;
|
|
517
|
-
var button = formEl.querySelector("button");
|
|
677
|
+
var button = formEl.querySelector("button[type=submit]");
|
|
518
678
|
button.disabled = true;
|
|
519
679
|
|
|
520
|
-
request("POST", "/messages",
|
|
521
|
-
body: body,
|
|
522
|
-
page_url: window.location.href,
|
|
523
|
-
locale: config.locale
|
|
524
|
-
})
|
|
680
|
+
request("POST", "/messages", buildMessagePayload(body))
|
|
525
681
|
.then(function (response) {
|
|
526
682
|
return response.json().then(function (data) {
|
|
527
683
|
return { ok: response.ok, data: data };
|
|
@@ -533,6 +689,7 @@
|
|
|
533
689
|
if (result.ok) {
|
|
534
690
|
inputEl.value = "";
|
|
535
691
|
autogrow();
|
|
692
|
+
clearFiles();
|
|
536
693
|
hasThread = true;
|
|
537
694
|
appendMessage(result.data.message);
|
|
538
695
|
syncEmailRow();
|
|
@@ -548,6 +705,20 @@
|
|
|
548
705
|
});
|
|
549
706
|
}
|
|
550
707
|
|
|
708
|
+
// Multipart only when there are files to carry — otherwise a plain JSON
|
|
709
|
+
// object, so text-only sends stay exactly as they were.
|
|
710
|
+
function buildMessagePayload(body) {
|
|
711
|
+
if (!pendingFiles.length) {
|
|
712
|
+
return { body: body, page_url: window.location.href, locale: config.locale };
|
|
713
|
+
}
|
|
714
|
+
var form = new FormData();
|
|
715
|
+
form.append("body", body);
|
|
716
|
+
form.append("page_url", window.location.href);
|
|
717
|
+
form.append("locale", config.locale);
|
|
718
|
+
pendingFiles.forEach(function (file) { form.append("files[]", file); });
|
|
719
|
+
return form;
|
|
720
|
+
}
|
|
721
|
+
|
|
551
722
|
function showError(text) {
|
|
552
723
|
errorEl.textContent = text;
|
|
553
724
|
errorEl.hidden = false;
|
|
@@ -564,8 +735,13 @@
|
|
|
564
735
|
if (method !== "GET") {
|
|
565
736
|
var token = document.querySelector('meta[name="csrf-token"]');
|
|
566
737
|
if (token) options.headers["X-CSRF-Token"] = token.content;
|
|
567
|
-
|
|
568
|
-
|
|
738
|
+
if (body instanceof FormData) {
|
|
739
|
+
// Let the browser set multipart Content-Type with its boundary.
|
|
740
|
+
options.body = body;
|
|
741
|
+
} else {
|
|
742
|
+
options.headers["Content-Type"] = "application/json";
|
|
743
|
+
options.body = JSON.stringify(body || {});
|
|
744
|
+
}
|
|
569
745
|
}
|
|
570
746
|
return fetch(config.endpoint + path, options);
|
|
571
747
|
}
|
|
@@ -652,6 +828,30 @@
|
|
|
652
828
|
"display:flex;align-items:center;justify-content:center;cursor:pointer}" +
|
|
653
829
|
"#lvc-form button:disabled{opacity:.6;cursor:default}" +
|
|
654
830
|
"#lvc-root[dir=rtl] #lvc-form button svg{transform:scaleX(-1)}" +
|
|
831
|
+
// Attach button sits alongside the send button, quieter (it's secondary).
|
|
832
|
+
"#lvc-attach{border:none;background:none;color:var(--lvc-muted);width:36px;min-width:36px;" +
|
|
833
|
+
"height:40px;align-self:flex-end;display:flex;align-items:center;justify-content:center;" +
|
|
834
|
+
"cursor:pointer;border-radius:10px}" +
|
|
835
|
+
"#lvc-attach:hover{background:var(--lvc-bg);color:var(--lvc-text)}" +
|
|
836
|
+
// Pending-file chips above the composer.
|
|
837
|
+
"#lvc-files{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px 0}" +
|
|
838
|
+
"#lvc-files[hidden]{display:none}" +
|
|
839
|
+
"#lvc-root .lvc-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;" +
|
|
840
|
+
"padding:4px 6px 4px 10px;border:1px solid var(--lvc-border);border-radius:999px;" +
|
|
841
|
+
"background:var(--lvc-bg);font-size:12px}" +
|
|
842
|
+
"#lvc-root .lvc-chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}" +
|
|
843
|
+
"#lvc-root .lvc-chip-x{border:none;background:none;color:var(--lvc-muted);font-size:16px;" +
|
|
844
|
+
"line-height:1;cursor:pointer;padding:0 2px}" +
|
|
845
|
+
"#lvc-root .lvc-chip-x:hover{color:var(--lvc-text)}" +
|
|
846
|
+
// Attachments inside a message bubble: thumbnails and file links.
|
|
847
|
+
"#lvc-root .lvc-atts{display:flex;flex-direction:column;gap:6px;margin-top:6px}" +
|
|
848
|
+
"#lvc-root .lvc-att-img{display:block}" +
|
|
849
|
+
"#lvc-root .lvc-att-img img{max-width:100%;max-height:220px;border-radius:8px;display:block}" +
|
|
850
|
+
"#lvc-root .lvc-att-file{display:inline-block;padding:6px 10px;border-radius:8px;" +
|
|
851
|
+
"background:rgba(0,0,0,.06);color:inherit;text-decoration:none;font-size:13px;" +
|
|
852
|
+
"overflow-wrap:anywhere}" +
|
|
853
|
+
"#lvc-root .lvc-att-file:hover{text-decoration:underline}" +
|
|
854
|
+
"#lvc-root .lvc-visitor .lvc-att-file{background:rgba(255,255,255,.2)}" +
|
|
655
855
|
// Phones get the whole screen — a chat is an app screen, not a popup.
|
|
656
856
|
// Selectors repeat the id twice so they outrank the rtl/no-launcher
|
|
657
857
|
// desktop rules regardless of source order.
|
data/lib/livechat/widget.rb
CHANGED
|
@@ -68,6 +68,8 @@ module Livechat
|
|
|
68
68
|
launcher: config.show_launcher ? true : false,
|
|
69
69
|
appName: Livechat.app_name,
|
|
70
70
|
accentColor: config.accent_color,
|
|
71
|
+
attachments: Livechat.attachments_enabled?,
|
|
72
|
+
maxAttachments: config.max_attachments,
|
|
71
73
|
labels: labels
|
|
72
74
|
}
|
|
73
75
|
# Escape "</" so a value can't close the <script> block early.
|
|
@@ -99,7 +101,10 @@ module Livechat
|
|
|
99
101
|
eventResolved: t(:event_resolved, 'Conversation resolved'),
|
|
100
102
|
eventReopened: t(:event_reopened, 'Conversation reopened'),
|
|
101
103
|
errorSend: t(:error_send, 'Could not send. Please try again.'),
|
|
102
|
-
unreadAria: t(:unread_aria, 'unread messages')
|
|
104
|
+
unreadAria: t(:unread_aria, 'unread messages'),
|
|
105
|
+
attach: t(:attach, 'Attach files'),
|
|
106
|
+
removeFile: t(:remove_file, 'Remove file'),
|
|
107
|
+
attachmentError: t(:attachment_error, 'Some files could not be sent.')
|
|
103
108
|
}
|
|
104
109
|
end
|
|
105
110
|
|
data/lib/livechat.rb
CHANGED
|
@@ -32,6 +32,37 @@ module Livechat
|
|
|
32
32
|
!!config.authorize_agent.call(request)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# File attachments are on only when the host has Active Storage AND hasn't
|
|
36
|
+
# switched them off. Guards every attachment path so the widget degrades
|
|
37
|
+
# to text-only rather than erroring where Active Storage is absent.
|
|
38
|
+
def attachments_enabled?
|
|
39
|
+
config.attach_files && Message.attachments_supported?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Realtime push is opt-in and needs Action Cable loaded. Off by default:
|
|
43
|
+
# the widget and inbox poll, and only speed up when a host turns this on.
|
|
44
|
+
def action_cable_enabled?
|
|
45
|
+
config.action_cable && defined?(ActionCable) ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Signs the Action Cable stream names handed to clients, so a subscriber
|
|
49
|
+
# can only listen to a conversation the server already let it see — the
|
|
50
|
+
# widget receives its stream token through the gated /conversation
|
|
51
|
+
# endpoint, never guessing one. Same idea as Turbo's signed streams.
|
|
52
|
+
def stream_verifier
|
|
53
|
+
@stream_verifier ||= Rails.application.message_verifier('livechat/stream')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sign_stream(name)
|
|
57
|
+
stream_verifier.generate(name.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def verify_stream(token)
|
|
61
|
+
stream_verifier.verify(token.to_s)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
35
66
|
def app_name
|
|
36
67
|
config.app_name.presence || rails_app_name
|
|
37
68
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: livechat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- README.md
|
|
44
44
|
- Rakefile
|
|
45
45
|
- app/controllers/livechat/application_controller.rb
|
|
46
|
+
- app/controllers/livechat/attachments_controller.rb
|
|
46
47
|
- app/controllers/livechat/conversations_controller.rb
|
|
47
48
|
- app/controllers/livechat/messages_controller.rb
|
|
48
49
|
- app/controllers/livechat/visitor_controller.rb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/generators/livechat/install/templates/create_livechat_tables.rb.tt
|
|
90
91
|
- lib/generators/livechat/install/templates/initializer.rb
|
|
91
92
|
- lib/livechat.rb
|
|
93
|
+
- lib/livechat/channels.rb
|
|
92
94
|
- lib/livechat/configuration.rb
|
|
93
95
|
- lib/livechat/dashboard.js
|
|
94
96
|
- lib/livechat/engine.rb
|