livechat 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab3b69e1c61320ea9df88571fc2fcad40c09c44e0f14f961b59a87d23ffc161e
4
- data.tar.gz: b6750a5aef1f99e7a6d2a49ed2fbcd6bb9cb1fb992cefd176e552f7d281f42e8
3
+ metadata.gz: 03a4a70e650dd115654bf8ab48e4afc02b5f628009732d3efbff4a7a36e278cc
4
+ data.tar.gz: e74596da328480eacab3097e9c08cc78464369ab2e9db6f9c015904498d53809
5
5
  SHA512:
6
- metadata.gz: 12421cafc2a629e4d1621dea03a984bd1e88d6ae9418390bd9767533208827da379f3d858cfd1c1eb1a6b47d2bb1aebaecedce8ff810f7f4fdc1197473c276e3
7
- data.tar.gz: 53b24b739628a3fdf69082ff652a1770d1acfa18c385152e61a2f9985faa82eac968f0c0be4ff36561957d9d8a3fa2bd53c7c671d2df02d0564ac10a96bc8eda
6
+ metadata.gz: 83f87fff5df20fc4b6ba70190fd66fc01dd4a32920f5acd36069d94c6f15eb8ab07711a865020b639ce2bd0c7d6555d606415844f2f7b639575d61dfd211fd16
7
+ data.tar.gz: ed3589f43b665ae857d6f9a918643feae0c239515f6413a41579a66e6caf4e4a1ca4bc356db9dfeeb274e6162c841649893dc87e535f91cb6733c4ca4f80dbc4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Inbox search: visitor name, email, and full message text, scoped to the
6
+ current tab.
7
+ - The conversation list keeps itself fresh (new messages, new threads,
8
+ resolves) — without ever reloading over a search in progress.
9
+ - An Agents column on the list shows which teammates have worked each thread.
10
+
3
11
  ## 0.2.1
4
12
 
5
13
  - With `show_launcher = false` the panel now sits in the corner instead of
data/README.md CHANGED
@@ -127,10 +127,12 @@ config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
127
127
 
128
128
  ## The inbox
129
129
 
130
- Browse at the mount path: open and resolved tabs, unread badges, one click
131
- into a thread. Reply (Cmd/Ctrl+Enter sends), resolve, reopen. The thread
132
- updates itself while you watch and never reloads over a half-written
133
- reply. Gated by `config.authorize_agent` (development-only until you set it).
130
+ Browse at the mount path: open and resolved tabs, unread badges, search
131
+ (visitor name, email, and everything anyone wrote), and a column showing
132
+ which teammates have worked each thread. One click into a thread; reply
133
+ (Cmd/Ctrl+Enter sends), resolve, reopen. Both pages keep themselves fresh
134
+ while you watch — and never reload over a half-written reply or search.
135
+ Gated by `config.authorize_agent` (development-only until you set it).
134
136
 
135
137
  ## Widget API
136
138
 
@@ -6,25 +6,39 @@ module Livechat
6
6
  # not an assignment queue.
7
7
  class ConversationsController < ApplicationController
8
8
  before_action :require_agent
9
- before_action :set_conversation, except: :index
9
+ before_action :set_conversation, except: %i[index index_poll]
10
10
  layout 'livechat/application'
11
11
 
12
12
  PER_PAGE = 50
13
13
 
14
14
  def index
15
15
  @status = Conversation::STATUSES.include?(params[:status]) ? params[:status] : 'open'
16
+ @query = params[:q].to_s.strip.presence
16
17
  @counts = Conversation.group(:status).count
17
18
  @offset = params[:offset].to_i.clamp(0, 1_000_000)
18
19
 
19
20
  scope = Conversation.where(status: @status).recent_first
21
+ scope = search(scope, @query) if @query
20
22
  page = scope.offset(@offset).limit(PER_PAGE + 1).to_a
21
23
  @more = page.size > PER_PAGE
22
24
  @conversations = page.first(PER_PAGE)
23
25
 
24
- # Unread badges for the whole page in one query.
25
- @unread = Message.from_visitor.unread
26
- .where(conversation_id: @conversations.map(&:id))
26
+ # Unread badges and participating agents for the whole page, one query each.
27
+ ids = @conversations.map(&:id)
28
+ @unread = Message.from_visitor.unread.where(conversation_id: ids)
27
29
  .group(:conversation_id).count
30
+ @agents = Message.from_agent.where(conversation_id: ids)
31
+ .distinct.pluck(:conversation_id, :agent_label)
32
+ .group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
33
+ end
34
+
35
+ # Polled by dashboard.js on the list page: a token that changes whenever
36
+ # anything an agent can see there changes — new message, new thread,
37
+ # resolve/reopen.
38
+ def index_poll
39
+ render json: { token: [Message.maximum(:id).to_i,
40
+ Conversation.count,
41
+ Conversation.where(status: 'open').count].join('-') }
28
42
  end
29
43
 
30
44
  def show
@@ -53,5 +67,17 @@ module Livechat
53
67
  def set_conversation
54
68
  @conversation = Conversation.find(params[:id])
55
69
  end
70
+
71
+ # Case-insensitive match on who the visitor is or anything anyone wrote.
72
+ # LOWER(...) LIKE is deliberately plain SQL — portable across SQLite,
73
+ # PostgreSQL and MySQL alike (feedback_engine's proven approach).
74
+ def search(scope, query)
75
+ q = "%#{ActiveRecord::Base.sanitize_sql_like(query.downcase)}%"
76
+ scope.where(
77
+ 'LOWER(visitor_label) LIKE :q OR LOWER(visitor_email) LIKE :q OR id IN ' \
78
+ "(SELECT conversation_id FROM #{Message.table_name} WHERE LOWER(body) LIKE :q)",
79
+ q: q
80
+ )
81
+ end
56
82
  end
57
83
  end
@@ -35,6 +35,9 @@
35
35
  .tabs a:hover { text-decoration: none; color: var(--text); }
36
36
  .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
37
37
  background: var(--border); font-size: 12px; text-align: center; }
38
+ .filters { margin-bottom: 16px; display: flex; gap: 8px; }
39
+ .filters input[type=search] { flex: 1; max-width: 320px; padding: 6px 10px; border: 1px solid var(--border);
40
+ border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; }
38
41
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
39
42
  .card.pad { padding: 16px; }
40
43
  table { width: 100%; border-collapse: collapse; }
@@ -2,13 +2,27 @@
2
2
 
3
3
  <div class="tabs">
4
4
  <% Livechat::Conversation::STATUSES.each do |status| %>
5
- <%= link_to conversations_path(status: status), class: ('active' if @status == status) do %>
5
+ <%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
6
6
  <%= t("livechat.statuses.#{status}", default: status.humanize) %>
7
7
  <span class="count"><%= @counts.fetch(status, 0) %></span>
8
8
  <% end %>
9
9
  <% end %>
10
10
  </div>
11
11
 
12
+ <%= form_with url: conversations_path, method: :get, class: 'filters' do %>
13
+ <input type="hidden" name="status" value="<%= @status %>">
14
+ <input type="search" name="q" value="<%= @query %>"
15
+ placeholder="<%= t('livechat.dashboard.search', default: 'Search') %>"
16
+ aria-label="<%= t('livechat.dashboard.search', default: 'Search') %>">
17
+ <button type="submit"><%= t('livechat.dashboard.search', default: 'Search') %></button>
18
+ <% end %>
19
+
20
+ <%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
21
+ unless the agent is mid-search, whose typing must never be eaten. %>
22
+ <div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
23
+ data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
24
+ @counts.fetch('open', 0)].join('-') %>"></div>
25
+
12
26
  <div class="card">
13
27
  <% if @conversations.empty? %>
14
28
  <p class="empty"><%= t('livechat.dashboard.empty',
@@ -20,6 +34,7 @@
20
34
  <th>#</th>
21
35
  <th><%= t('livechat.dashboard.visitor', default: 'Visitor') %></th>
22
36
  <th><%= t('livechat.dashboard.last_message', default: 'Last message') %></th>
37
+ <th><%= t('livechat.dashboard.agents', default: 'Agents') %></th>
23
38
  <th><%= t('livechat.dashboard.updated', default: 'Updated') %></th>
24
39
  </tr>
25
40
  </thead>
@@ -38,6 +53,7 @@
38
53
  <% end %>
39
54
  </td>
40
55
  <td class="muted"><%= conversation.last_message_preview %></td>
56
+ <td class="muted"><%= @agents.fetch(conversation.id, []).join(', ') %></td>
41
57
  <td class="muted">
42
58
  <% if conversation.last_activity_at %>
43
59
  <%= time_ago_in_words(conversation.last_activity_at) %>
@@ -53,10 +69,12 @@
53
69
  <div class="pager">
54
70
  <% if @offset.positive? %>
55
71
  <%= link_to t('livechat.dashboard.newer', default: 'Newer'),
56
- conversations_path(status: @status, offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
72
+ conversations_path(status: @status, q: @query,
73
+ offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
57
74
  <% end %>
58
75
  <% if @more %>
59
76
  <%= link_to t('livechat.dashboard.older', default: 'Older'),
60
- conversations_path(status: @status, offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
77
+ conversations_path(status: @status, q: @query,
78
+ offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
61
79
  <% end %>
62
80
  </div>
@@ -29,6 +29,8 @@ ar:
29
29
  visitor: "الزائر"
30
30
  last_message: "آخر رسالة"
31
31
  updated: "آخر تحديث"
32
+ agents: "الوكلاء"
33
+ search: "بحث"
32
34
  back: "جميع المحادثات"
33
35
  resolve: "حل"
34
36
  reopen: "إعادة فتح"
@@ -29,6 +29,8 @@ bg:
29
29
  visitor: "Посетител"
30
30
  last_message: "Последно съобщение"
31
31
  updated: "Обновено"
32
+ agents: "Агенти"
33
+ search: "Търсене"
32
34
  back: "Всички разговори"
33
35
  resolve: "Разреши"
34
36
  reopen: "Отвори отново"
@@ -29,6 +29,8 @@ bn:
29
29
  visitor: "দর্শনার্থী"
30
30
  last_message: "শেষ বার্তা"
31
31
  updated: "আপডেট হয়েছে"
32
+ agents: "এজেন্ট"
33
+ search: "অনুসন্ধান"
32
34
  back: "সব কথোপকথন"
33
35
  resolve: "সমাধান করুন"
34
36
  reopen: "আবার খুলুন"
@@ -29,6 +29,8 @@ de:
29
29
  visitor: "Besucher"
30
30
  last_message: "Letzte Nachricht"
31
31
  updated: "Aktualisiert"
32
+ agents: "Agenten"
33
+ search: "Suchen"
32
34
  back: "Alle Unterhaltungen"
33
35
  resolve: "Lösen"
34
36
  reopen: "Wieder öffnen"
@@ -29,6 +29,8 @@ el:
29
29
  visitor: "Επισκέπτης"
30
30
  last_message: "Τελευταίο μήνυμα"
31
31
  updated: "Ενημερώθηκε"
32
+ agents: "Εκπρόσωποι"
33
+ search: "Αναζήτηση"
32
34
  back: "Όλες οι συνομιλίες"
33
35
  resolve: "Επίλυση"
34
36
  reopen: "Άνοιγμα ξανά"
@@ -29,6 +29,8 @@ en:
29
29
  visitor: "Visitor"
30
30
  last_message: "Last message"
31
31
  updated: "Updated"
32
+ agents: "Agents"
33
+ search: "Search"
32
34
  back: "All conversations"
33
35
  resolve: "Resolve"
34
36
  reopen: "Reopen"
@@ -29,6 +29,8 @@ es:
29
29
  visitor: "Visitante"
30
30
  last_message: "Último mensaje"
31
31
  updated: "Actualizado"
32
+ agents: "Agentes"
33
+ search: "Buscar"
32
34
  back: "Todas las conversaciones"
33
35
  resolve: "Resolver"
34
36
  reopen: "Reabrir"
@@ -29,6 +29,8 @@ fr:
29
29
  visitor: "Visiteur"
30
30
  last_message: "Dernier message"
31
31
  updated: "Mise à jour"
32
+ agents: "Agents"
33
+ search: "Rechercher"
32
34
  back: "Toutes les conversations"
33
35
  resolve: "Résoudre"
34
36
  reopen: "Rouvrir"
@@ -29,6 +29,8 @@ hi:
29
29
  visitor: "विज़िटर"
30
30
  last_message: "आख़िरी संदेश"
31
31
  updated: "अपडेट किया गया"
32
+ agents: "एजेंट"
33
+ search: "खोजें"
32
34
  back: "सभी बातचीत"
33
35
  resolve: "सुलझाएं"
34
36
  reopen: "फिर से खोलें"
@@ -29,6 +29,8 @@ hr:
29
29
  visitor: "Posjetitelj"
30
30
  last_message: "Zadnja poruka"
31
31
  updated: "Ažurirano"
32
+ agents: "Agenti"
33
+ search: "Pretraži"
32
34
  back: "Svi razgovori"
33
35
  resolve: "Riješi"
34
36
  reopen: "Ponovno otvori"
@@ -29,6 +29,8 @@ id:
29
29
  visitor: "Pengunjung"
30
30
  last_message: "Pesan terakhir"
31
31
  updated: "Diperbarui"
32
+ agents: "Agen"
33
+ search: "Cari"
32
34
  back: "Semua percakapan"
33
35
  resolve: "Selesaikan"
34
36
  reopen: "Buka kembali"
@@ -29,6 +29,8 @@ it:
29
29
  visitor: "Visitatore"
30
30
  last_message: "Ultimo messaggio"
31
31
  updated: "Aggiornata"
32
+ agents: "Agenti"
33
+ search: "Cerca"
32
34
  back: "Tutte le conversazioni"
33
35
  resolve: "Risolvi"
34
36
  reopen: "Riapri"
@@ -29,6 +29,8 @@ ja:
29
29
  visitor: "訪問者"
30
30
  last_message: "最後のメッセージ"
31
31
  updated: "更新"
32
+ agents: "担当者"
33
+ search: "検索"
32
34
  back: "すべての会話"
33
35
  resolve: "解決"
34
36
  reopen: "再開"
@@ -29,6 +29,8 @@ ko:
29
29
  visitor: "방문자"
30
30
  last_message: "마지막 메시지"
31
31
  updated: "업데이트"
32
+ agents: "상담원"
33
+ search: "검색"
32
34
  back: "모든 대화"
33
35
  resolve: "해결"
34
36
  reopen: "다시 열기"
@@ -29,6 +29,8 @@ lb:
29
29
  visitor: "Besucher"
30
30
  last_message: "Lescht Noriicht"
31
31
  updated: "Aktualiséiert"
32
+ agents: "Agenten"
33
+ search: "Sichen"
32
34
  back: "All Gespréicher"
33
35
  resolve: "Léisen"
34
36
  reopen: "Nees opmaachen"
@@ -29,6 +29,8 @@ nl:
29
29
  visitor: "Bezoeker"
30
30
  last_message: "Laatste bericht"
31
31
  updated: "Bijgewerkt"
32
+ agents: "Agenten"
33
+ search: "Zoeken"
32
34
  back: "Alle gesprekken"
33
35
  resolve: "Oplossen"
34
36
  reopen: "Heropenen"
@@ -29,6 +29,8 @@ pl:
29
29
  visitor: "Odwiedzający"
30
30
  last_message: "Ostatnia wiadomość"
31
31
  updated: "Zaktualizowano"
32
+ agents: "Agenci"
33
+ search: "Szukaj"
32
34
  back: "Wszystkie rozmowy"
33
35
  resolve: "Rozwiąż"
34
36
  reopen: "Wznów"
@@ -29,6 +29,8 @@ pt:
29
29
  visitor: "Visitante"
30
30
  last_message: "Última mensagem"
31
31
  updated: "Atualizada"
32
+ agents: "Agentes"
33
+ search: "Pesquisar"
32
34
  back: "Todas as conversas"
33
35
  resolve: "Resolver"
34
36
  reopen: "Reabrir"
@@ -29,6 +29,8 @@ ro:
29
29
  visitor: "Vizitator"
30
30
  last_message: "Ultimul mesaj"
31
31
  updated: "Actualizat"
32
+ agents: "Agenți"
33
+ search: "Căutare"
32
34
  back: "Toate conversațiile"
33
35
  resolve: "Rezolvă"
34
36
  reopen: "Redeschide"
@@ -29,6 +29,8 @@ ru:
29
29
  visitor: "Посетитель"
30
30
  last_message: "Последнее сообщение"
31
31
  updated: "Обновлено"
32
+ agents: "Агенты"
33
+ search: "Поиск"
32
34
  back: "Все диалоги"
33
35
  resolve: "Завершить"
34
36
  reopen: "Возобновить"
@@ -29,6 +29,8 @@ th:
29
29
  visitor: "ผู้เยี่ยมชม"
30
30
  last_message: "ข้อความล่าสุด"
31
31
  updated: "อัปเดตเมื่อ"
32
+ agents: "เจ้าหน้าที่"
33
+ search: "ค้นหา"
32
34
  back: "การสนทนาทั้งหมด"
33
35
  resolve: "แก้ไขเสร็จสิ้น"
34
36
  reopen: "เปิดอีกครั้ง"
@@ -29,6 +29,8 @@ tr:
29
29
  visitor: "Ziyaretçi"
30
30
  last_message: "Son mesaj"
31
31
  updated: "Güncellendi"
32
+ agents: "Temsilciler"
33
+ search: "Ara"
32
34
  back: "Tüm görüşmeler"
33
35
  resolve: "Çöz"
34
36
  reopen: "Yeniden aç"
@@ -29,6 +29,8 @@ uk:
29
29
  visitor: "Відвідувач"
30
30
  last_message: "Останнє повідомлення"
31
31
  updated: "Оновлено"
32
+ agents: "Агенти"
33
+ search: "Пошук"
32
34
  back: "Усі розмови"
33
35
  resolve: "Завершити"
34
36
  reopen: "Відновити"
@@ -29,6 +29,8 @@ ur:
29
29
  visitor: "وزیٹر"
30
30
  last_message: "آخری پیغام"
31
31
  updated: "اپ ڈیٹ"
32
+ agents: "ایجنٹس"
33
+ search: "تلاش"
32
34
  back: "تمام گفتگوئیں"
33
35
  resolve: "حل کریں"
34
36
  reopen: "دوبارہ کھولیں"
@@ -29,6 +29,8 @@ vi:
29
29
  visitor: "Khách truy cập"
30
30
  last_message: "Tin nhắn cuối"
31
31
  updated: "Đã cập nhật"
32
+ agents: "Nhân viên"
33
+ search: "Tìm kiếm"
32
34
  back: "Tất cả cuộc trò chuyện"
33
35
  resolve: "Giải quyết"
34
36
  reopen: "Mở lại"
@@ -29,6 +29,8 @@
29
29
  visitor: "访客"
30
30
  last_message: "最后一条消息"
31
31
  updated: "更新时间"
32
+ agents: "客服"
33
+ search: "搜索"
32
34
  back: "所有对话"
33
35
  resolve: "解决"
34
36
  reopen: "重新打开"
data/config/routes.rb CHANGED
@@ -15,6 +15,9 @@ Livechat::Engine.routes.draw do
15
15
 
16
16
  # The inbox. Flat, human URLs: the mount path IS the conversation list.
17
17
  resources :conversations, path: '', only: %i[index show], constraints: { id: /\d+/ } do
18
+ collection do
19
+ get :poll, action: :index_poll
20
+ end
18
21
  member do
19
22
  get :poll
20
23
  post :resolve
@@ -21,6 +21,35 @@
21
21
  }
22
22
 
23
23
  ready(function () {
24
+ watchIndex();
25
+ watchThread();
26
+ });
27
+
28
+ // The conversation list keeps itself fresh: when the server's token moves
29
+ // (new message, new thread, resolve/reopen), just reload — unless the
30
+ // agent is mid-search, whose typing must never be eaten.
31
+ function watchIndex() {
32
+ var index = document.getElementById("lvc-index");
33
+ if (!index) return;
34
+
35
+ var token = index.getAttribute("data-token");
36
+ var url = index.getAttribute("data-poll-url");
37
+ var searchBox = document.querySelector(".filters input[type=search]");
38
+
39
+ setInterval(function () {
40
+ if (document.hidden) return;
41
+ if (searchBox && searchBox.value !== searchBox.defaultValue) return;
42
+ if (searchBox && document.activeElement === searchBox) return;
43
+ fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
44
+ .then(function (response) { return response.ok ? response.json() : null; })
45
+ .then(function (data) {
46
+ if (data && data.token !== token) window.location.reload();
47
+ })
48
+ .catch(function () { /* transient network error — next tick retries */ });
49
+ }, POLL_MS);
50
+ }
51
+
52
+ function watchThread() {
24
53
  var thread = document.getElementById("thread");
25
54
  if (!thread) return;
26
55
 
@@ -54,5 +83,5 @@
54
83
  })
55
84
  .catch(function () { /* transient network error — next tick retries */ });
56
85
  }, POLL_MS);
57
- });
86
+ }
58
87
  })();
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.2.1'
4
+ VERSION = '0.3.0'
5
5
  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.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov