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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -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 +210 -10
  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.6'
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") {
@@ -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 = "&times;";
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.textContent = message.body;
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
- options.headers["Content-Type"] = "application/json";
568
- 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
+ }
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.
@@ -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.6
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