livechat 0.3.5 → 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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +47 -1
  4. data/app/controllers/livechat/attachments_controller.rb +61 -0
  5. data/app/controllers/livechat/conversations_controller.rb +1 -17
  6. data/app/controllers/livechat/messages_controller.rb +2 -1
  7. data/app/controllers/livechat/visitor_controller.rb +13 -2
  8. data/app/helpers/livechat/inbox_helper.rb +32 -0
  9. data/app/models/livechat/conversation.rb +22 -6
  10. data/app/models/livechat/message.rb +110 -2
  11. data/app/views/layouts/livechat/application.html.erb +9 -1
  12. data/app/views/livechat/conversations/index.html.erb +3 -1
  13. data/app/views/livechat/conversations/show.html.erb +14 -5
  14. data/config/locales/livechat.ar.yml +8 -0
  15. data/config/locales/livechat.bg.yml +8 -0
  16. data/config/locales/livechat.bn.yml +8 -0
  17. data/config/locales/livechat.de.yml +8 -0
  18. data/config/locales/livechat.el.yml +8 -0
  19. data/config/locales/livechat.en.yml +8 -0
  20. data/config/locales/livechat.es.yml +8 -0
  21. data/config/locales/livechat.fr.yml +8 -0
  22. data/config/locales/livechat.hi.yml +8 -0
  23. data/config/locales/livechat.hr.yml +8 -0
  24. data/config/locales/livechat.id.yml +8 -0
  25. data/config/locales/livechat.it.yml +8 -0
  26. data/config/locales/livechat.ja.yml +8 -0
  27. data/config/locales/livechat.ko.yml +8 -0
  28. data/config/locales/livechat.lb.yml +8 -0
  29. data/config/locales/livechat.nl.yml +8 -0
  30. data/config/locales/livechat.pl.yml +8 -0
  31. data/config/locales/livechat.pt.yml +8 -0
  32. data/config/locales/livechat.ro.yml +8 -0
  33. data/config/locales/livechat.ru.yml +8 -0
  34. data/config/locales/livechat.th.yml +8 -0
  35. data/config/locales/livechat.tr.yml +8 -0
  36. data/config/locales/livechat.uk.yml +8 -0
  37. data/config/locales/livechat.ur.yml +8 -0
  38. data/config/locales/livechat.vi.yml +8 -0
  39. data/config/locales/livechat.zh-CN.yml +8 -0
  40. data/config/routes.rb +6 -0
  41. data/lib/generators/livechat/install/templates/initializer.rb +15 -0
  42. data/lib/livechat/channels.rb +27 -0
  43. data/lib/livechat/configuration.rb +28 -0
  44. data/lib/livechat/dashboard.js +73 -5
  45. data/lib/livechat/engine.rb +7 -0
  46. data/lib/livechat/version.rb +1 -1
  47. data/lib/livechat/widget.js +220 -12
  48. data/lib/livechat/widget.rb +6 -1
  49. data/lib/livechat.rb +31 -0
  50. metadata +3 -1
@@ -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
- setInterval(function () {
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
- }, POLL_MS);
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.textContent = message.body;
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
- setInterval(function () {
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
- }, POLL_MS);
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
  })();
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.3.5'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -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") {
@@ -207,8 +219,17 @@
207
219
  listEl.id = "lvc-list";
208
220
  listEl.setAttribute("role", "log");
209
221
  listEl.setAttribute("aria-live", "polite");
222
+ // The opening greeting, styled as a support message so the panel reads
223
+ // like a conversation that's already been warmly opened. Deliberately a
224
+ // generic team label — never a fake individual, avatar, or presence
225
+ // (livechat is honest async support). It's client-only: never stored,
226
+ // never shown in the agent's inbox.
227
+ var greetingWho = document.createElement("div");
228
+ greetingWho.className = "lvc-who lvc-who-agent";
229
+ greetingWho.textContent = config.labels.team;
230
+ listEl.appendChild(greetingWho);
210
231
  var greeting = document.createElement("div");
211
- greeting.className = "lvc-greeting";
232
+ greeting.className = "lvc-msg lvc-agent";
212
233
  greeting.textContent = config.labels.greeting;
213
234
  listEl.appendChild(greeting);
214
235
  panel.appendChild(listEl);
@@ -254,11 +275,87 @@
254
275
  event.preventDefault();
255
276
  send();
256
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
+
257
312
  panel.appendChild(formEl);
258
313
 
259
314
  return panel;
260
315
  }
261
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 = "&times;";
352
+ remove.addEventListener("click", function () { removeFile(index); });
353
+ chip.appendChild(name);
354
+ chip.appendChild(remove);
355
+ filesBarEl.appendChild(chip);
356
+ });
357
+ }
358
+
262
359
  // Guests only: one quiet row under the thread asking for an email, shown
263
360
  // once they have written and until they save one.
264
361
  function buildEmailRow() {
@@ -406,6 +503,7 @@
406
503
 
407
504
  hasThread = data.status !== null;
408
505
  hasEmail = !!data.email;
506
+ if (data.cable) ensureCable(data.cable);
409
507
 
410
508
  if (isOpen) {
411
509
  // Only an open panel renders (and thereby "consumes") messages;
@@ -490,29 +588,96 @@
490
588
 
491
589
  var bubble = document.createElement("div");
492
590
  bubble.className = "lvc-msg lvc-" + message.author;
493
- bubble.textContent = message.body;
591
+ if (message.body) bubble.appendChild(document.createTextNode(message.body));
592
+ renderAttachments(bubble, message.attachments);
494
593
  bubble.title = new Date(message.at).toLocaleString();
495
594
  listEl.appendChild(bubble);
496
595
  scrollToBottom();
497
596
  }
498
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
+
499
625
  function scrollToBottom() {
500
626
  if (listEl) listEl.scrollTop = listEl.scrollHeight;
501
627
  }
502
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
+
503
672
  function send() {
504
673
  var body = (inputEl.value || "").trim();
505
- if (!body || sending) return;
674
+ if ((!body && !pendingFiles.length) || sending) return;
506
675
  sending = true;
507
676
  errorEl.hidden = true;
508
- var button = formEl.querySelector("button");
677
+ var button = formEl.querySelector("button[type=submit]");
509
678
  button.disabled = true;
510
679
 
511
- request("POST", "/messages", {
512
- body: body,
513
- page_url: window.location.href,
514
- locale: config.locale
515
- })
680
+ request("POST", "/messages", buildMessagePayload(body))
516
681
  .then(function (response) {
517
682
  return response.json().then(function (data) {
518
683
  return { ok: response.ok, data: data };
@@ -524,6 +689,7 @@
524
689
  if (result.ok) {
525
690
  inputEl.value = "";
526
691
  autogrow();
692
+ clearFiles();
527
693
  hasThread = true;
528
694
  appendMessage(result.data.message);
529
695
  syncEmailRow();
@@ -539,6 +705,20 @@
539
705
  });
540
706
  }
541
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
+
542
722
  function showError(text) {
543
723
  errorEl.textContent = text;
544
724
  errorEl.hidden = false;
@@ -555,8 +735,13 @@
555
735
  if (method !== "GET") {
556
736
  var token = document.querySelector('meta[name="csrf-token"]');
557
737
  if (token) options.headers["X-CSRF-Token"] = token.content;
558
- options.headers["Content-Type"] = "application/json";
559
- options.body = JSON.stringify(body || {});
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
+ }
560
745
  }
561
746
  return fetch(config.endpoint + path, options);
562
747
  }
@@ -611,7 +796,6 @@
611
796
  "background:var(--lvc-bg);display:flex;flex-direction:column;gap:4px}" +
612
797
  // Class rules carry the #lvc-root prefix so they outrank the
613
798
  // id-level `#lvc-root *` reset above.
614
- "#lvc-root .lvc-greeting{color:var(--lvc-muted);font-size:13px;margin-bottom:8px}" +
615
799
  "#lvc-root .lvc-who{font-size:11px;color:var(--lvc-muted);margin:8px 4px 2px}" +
616
800
  "#lvc-root .lvc-who-visitor{text-align:right}" +
617
801
  "#lvc-root[dir=rtl] .lvc-who-visitor{text-align:left}" +
@@ -644,6 +828,30 @@
644
828
  "display:flex;align-items:center;justify-content:center;cursor:pointer}" +
645
829
  "#lvc-form button:disabled{opacity:.6;cursor:default}" +
646
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)}" +
647
855
  // Phones get the whole screen — a chat is an app screen, not a popup.
648
856
  // Selectors repeat the id twice so they outrank the rtl/no-launcher
649
857
  // desktop rules regardless of source order.
@@ -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.3.5
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