livechat 0.6.0 → 0.6.2

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: aee50137fc34157ea3fff75a2095055b4224c2d0ced2524ea13278135256b07f
4
- data.tar.gz: 358aa67a9cb0a4b30ee961078cf4d8ae740da6fbbe04cbdcba987fc68c7e866f
3
+ metadata.gz: ebf70396ba82be98cc6f9332c276c5314a14b3ba4330f99646505ce1dd293e7e
4
+ data.tar.gz: f48e3578174194d4a501cfc2fd30a7f5772e58b8b9a79399c46073eb4bdac003
5
5
  SHA512:
6
- metadata.gz: 45f8656ac4857d04970159bfc75052b75da1152b993d83a4c7c743589cda5e78cf5e47c2de7607c2c94d4fafdce880c50dd36b02000dcc2ec1e4a16343c4821e
7
- data.tar.gz: c7e6e959cd01488ca58a8e59ed943da3233d9e9e3b6670aac19be11915a8595e8ec680bac0b4ad262a19b755be42da7bbf16af007e56815fed7f8aaffd7e5683
6
+ metadata.gz: ef83bc9a0d405fc0aa5ad055a12243e83a0a2d35d1f9ed058c68b27381c9965575747372992f69fc87fe70a909e8b06f44a7b31df410216e758a7a5aed9c0910
7
+ data.tar.gz: f77398569393337ae0cf9f023bb887c3ac0d15aa07b34b05669d98af643124f949a41b4d77089b14b30a116e9cfec6fb350689a9d59316f20f5353991bc7aeca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2
4
+
5
+ - Reworked the inbox into a two-column desktop layout: conversation list on the
6
+ left, selected thread on the right, with live message polling preserved.
7
+ - Improved the conversation list with compact rows showing the visitor, last
8
+ message preview, unread count, relative activity time, and participating
9
+ agent avatars.
10
+ - Kept mobile as a focused single-pane inbox/thread flow.
11
+
12
+ ## 0.6.1
13
+
14
+ - Repositioned the docs around in-app support messaging for Rails: users ask
15
+ for help from the page where they got stuck, and teams answer from the
16
+ mounted inbox without SaaS, third-party scripts, or a separate support app.
17
+ - Added `message:` to `livechat_button`, so contextual support buttons can open
18
+ the widget and prefill the composer in one helper call.
19
+ - Updated the PRD to match the current shipped feature set through 0.6.0.
20
+
3
21
  ## 0.6.0
4
22
 
5
23
  - Improved unread notification emails. Team emails now summarize the unread
data/README.md CHANGED
@@ -5,9 +5,13 @@
5
5
  [![CI](https://github.com/yshmarov/livechat/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
7
 
8
- **Open-source live chat for Rails.** A chat bubble for your users, an inbox for
9
- your team as a gem, not another service to deploy. Alternative to Crisp,
10
- Intercom and Chatwoot.
8
+ **In-app support messaging for Rails.** Add a chat button so users can ask for
9
+ help from the page where they got stuck. Your team answers from a mounted inbox.
10
+ No SaaS account, no third-party script, no separate app, no customer data leaving
11
+ your database.
12
+
13
+ Use it when email is too detached, Intercom is too much, and Chatwoot is another
14
+ app you do not want to deploy.
11
15
 
12
16
  ![The livechat widget open over a running Rails app](docs/screenshots/01-widget.png)
13
17
 
@@ -86,14 +90,15 @@ fallback.
86
90
 
87
91
  ## The inbox
88
92
 
89
- Open and resolved tabs, unread badges, search (visitor name, email, and
90
- everything anyone wrote), and a column showing which teammates have worked each
91
- thread. Reply with Cmd/Ctrl+Enter, resolve, reopen. Both pages keep themselves
92
- fresh while you watch — and never reload over a half-written reply or search.
93
+ Open and resolved filters, unread badges, search (visitor name, email, and
94
+ everything anyone wrote), and a two-column desktop layout with the chat list on
95
+ the left and the selected thread on the right. Reply with Cmd/Ctrl+Enter,
96
+ resolve, reopen. The inbox keeps itself fresh while you watch — and never
97
+ reloads over a half-written reply or search.
93
98
 
94
- | The list | The thread |
99
+ | Desktop inbox | Mobile thread |
95
100
  | --- | --- |
96
- | ![The inbox: open and resolved tabs, search, and a table of conversations](docs/screenshots/03-inbox.png) | ![A conversation: signed replies, timestamps, resolve event and composer](docs/screenshots/04-conversation.png) |
101
+ | ![The inbox: conversation list on the left and selected thread on the right](docs/screenshots/03-inbox.png) | ![The mobile thread view with reply composer](docs/screenshots/04-conversation.png) |
97
102
 
98
103
  Every reply carries its author and time, and resolving is recorded in the
99
104
  thread. Gated by `authorize_agent`.
@@ -232,11 +237,35 @@ to a stream the server signed for it. Under a strict CSP, allow the socket with
232
237
  | `data-livechat-open` | Any element opens the panel on click |
233
238
  | `data-livechat-message="…"` | Prefill from that element — great for contextual buttons |
234
239
  | `<%= livechat_button %>` | A plain, unstyled opener button |
240
+ | `<%= livechat_button("Ask about this order", message: "I need help with order ##{@order.id}") %>` | Open and prefill in one helper |
235
241
  | `config.show_launcher = false` | Hide the bubble entirely |
236
242
 
237
243
  While replies are unread, every `data-livechat-open` element carries a small
238
244
  count badge — so hiding the launcher never hides the answer.
239
245
 
246
+ Contextual buttons are where a Rails app beats a generic support widget:
247
+
248
+ ```erb
249
+ <%= livechat_button("Ask about this order",
250
+ message: "I need help with order ##{@order.id}") %>
251
+
252
+ <%= livechat_button("Ask about this invoice",
253
+ message: "I need help with invoice ##{@invoice.number}") %>
254
+
255
+ <button data-livechat-open
256
+ data-livechat-message="I need help with project <%= @project.name %>">
257
+ Contact support
258
+ </button>
259
+ ```
260
+
261
+ ## Use cases
262
+
263
+ - SaaS apps: answer billing, onboarding and account questions from inside the app.
264
+ - Customer portals: let users ask about orders, invoices, documents or bookings.
265
+ - Marketplaces: keep buyer, seller and admin support tied to the current page.
266
+ - Internal tools: give non-technical teammates a direct line from admin screens.
267
+ - Course and member apps: handle access, lesson and subscription questions in context.
268
+
240
269
  ## What it doesn't do
241
270
 
242
271
  No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no
@@ -23,6 +23,14 @@ module Livechat
23
23
  @more = page.size > PER_PAGE
24
24
  @conversations = page.first(PER_PAGE)
25
25
 
26
+ if params[:conversation_id].present?
27
+ @selected_conversation = Conversation.find_by(id: params[:conversation_id])
28
+ if @selected_conversation
29
+ @messages = @selected_conversation.messages.chronological.to_a
30
+ @selected_conversation.mark_read_for_agent!
31
+ end
32
+ end
33
+
26
34
  # Unread badges and participating agents for the whole page, one query each.
27
35
  ids = @conversations.map(&:id)
28
36
  @unread = Message.from_visitor.unread.where(conversation_id: ids)
@@ -70,12 +78,12 @@ module Livechat
70
78
 
71
79
  def resolve
72
80
  @conversation.resolve_by!(current_agent_label)
73
- redirect_to conversation_path(@conversation)
81
+ redirect_back fallback_location: conversation_path(@conversation)
74
82
  end
75
83
 
76
84
  def reopen
77
85
  @conversation.reopen_by!(current_agent_label)
78
- redirect_to conversation_path(@conversation)
86
+ redirect_back fallback_location: conversation_path(@conversation)
79
87
  end
80
88
 
81
89
  private
@@ -14,9 +14,9 @@ module Livechat
14
14
  )
15
15
  Notifications.agent_message(message)
16
16
 
17
- redirect_to conversation_path(conversation, anchor: "message-#{message.id}")
17
+ redirect_back fallback_location: conversation_path(conversation, anchor: "message-#{message.id}")
18
18
  rescue ActiveRecord::RecordInvalid => e
19
- redirect_to conversation_path(conversation), alert: e.record.errors.full_messages.first
19
+ redirect_back fallback_location: conversation_path(conversation), alert: e.record.errors.full_messages.first
20
20
  end
21
21
  end
22
22
  end
@@ -18,12 +18,17 @@ module Livechat
18
18
  end
19
19
 
20
20
  # A plain, unstyled <button> that opens the chat — it picks up the host's
21
- # own styles. Put it anywhere on a page that also renders livechat_tag.
22
- def livechat_button(label: nil, **)
21
+ # own styles. Pass message: to prefill the composer with page-specific
22
+ # context. Put it anywhere on a page that also renders livechat_tag.
23
+ def livechat_button(label = nil, message: nil, **options)
23
24
  return unless Livechat.enabled?(request)
24
25
 
26
+ label ||= options.delete(:label)
25
27
  label ||= I18n.t(:launcher, scope: :livechat, default: 'Chat with us')
26
- tag.button(label, type: 'button', 'data-livechat-open': '', **)
28
+ data = (options.delete(:data) { {} } || {}).merge(livechat_open: '')
29
+ data[:livechat_message] = message if message.present?
30
+
31
+ tag.button(label, type: 'button', data: data, **options)
27
32
  end
28
33
 
29
34
  private
@@ -142,6 +142,60 @@
142
142
  .reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%;
143
143
  background: var(--accent); color: var(--accent-text); cursor: pointer;
144
144
  display: flex; align-items: center; justify-content: center; }
145
+ /* Desktop inbox: a durable conversation list on the left and the active
146
+ thread on the right, with each pane owning its own scroll. */
147
+ .lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
148
+ .lvc-inbox { overflow: hidden; }
149
+ .lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
150
+ .lvc-inbox .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
151
+ .inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr;
152
+ gap: 16px; }
153
+ .inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
154
+ background: var(--surface); border: 1px solid var(--border);
155
+ border-radius: 8px; overflow: hidden; }
156
+ .inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
157
+ border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
158
+ .inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
159
+ justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px;
160
+ border-radius: 8px; color: var(--muted); font-weight: 600; }
161
+ .inbox-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
162
+ .inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0;
163
+ height: 2px; border-radius: 999px; background: var(--accent); }
164
+ .inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
165
+ .inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
166
+ .inbox-sidebar .tabs a.active .count { background: var(--border); }
167
+ .inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
168
+ .inbox-sidebar .filters input[type=search] { max-width: none; }
169
+ .conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
170
+ .conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
171
+ color: var(--text); border-bottom: 1px solid var(--border); }
172
+ .conversation-row:hover { text-decoration: none; background: var(--bg); }
173
+ .conversation-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
174
+ box-shadow: inset 3px 0 0 var(--accent); }
175
+ .conversation-row.unread .conversation-name, .conversation-row.unread .conversation-preview { font-weight: 700; }
176
+ .conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
177
+ .conversation-topline, .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
178
+ .conversation-name, .conversation-preview, .conversation-meta span { overflow: hidden; text-overflow: ellipsis;
179
+ white-space: nowrap; }
180
+ .conversation-name { font-weight: 600; }
181
+ .conversation-time { margin-left: auto; white-space: nowrap; }
182
+ .conversation-preview { display: block; }
183
+ .conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
184
+ gap: 8px; padding-top: 1px; }
185
+ .conversation-side .avatars { min-height: 26px; }
186
+ .inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
187
+ .inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
188
+ .inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
189
+ padding: 0; }
190
+ .conversation-panel .head-row, .conversation-panel .context-line { flex: 0 0 auto; }
191
+ .conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
192
+ border-radius: 8px; }
193
+ .conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
194
+ .conversation-panel .reply { flex: 0 0 auto; }
195
+ .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
196
+ .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
197
+ .empty-state p { margin: 0; }
198
+ .mobile-back { display: none; }
145
199
  /* Conversation page: fill the viewport and scroll the THREAD, not the
146
200
  page — so a reply never jumps the page, and the composer stays put. */
147
201
  .lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
@@ -152,6 +206,25 @@
152
206
  .lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
153
207
  .lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
154
208
  .lvc-convo .reply { flex: 0 0 auto; }
209
+ @media (max-width: 760px) {
210
+ body { font-size: 14px; }
211
+ .container { padding: 14px 10px 24px; }
212
+ .lvc-inbox { overflow: auto; }
213
+ .lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
214
+ .lvc-inbox .container { padding-bottom: 10px; }
215
+ .inbox-shell { display: block; min-height: calc(100vh - 62px); }
216
+ .inbox-sidebar, .inbox-thread { min-height: calc(100vh - 62px); }
217
+ .inbox-shell.has-selected .inbox-sidebar { display: none; }
218
+ .inbox-shell:not(.has-selected) .inbox-thread { display: none; }
219
+ .conversation-list { overflow: visible; }
220
+ .conversation-row { padding: 12px; }
221
+ .conversation-meta { display: none; }
222
+ .conversation-panel { min-height: calc(100vh - 64px); }
223
+ .inbox-thread .conversation-panel { padding: 0; }
224
+ .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
225
+ .conversation-panel .head-row h1 { font-size: 18px; }
226
+ .thread .msg { max-width: 88%; }
227
+ }
155
228
  </style>
156
229
  </head>
157
230
  <body class="<%= yield(:body_class) %>">
@@ -0,0 +1,95 @@
1
+ <div class="conversation-panel">
2
+ <div class="head-row">
3
+ <h1><%= conversation.display_name %> <span class="case-id">#<%= conversation.id %></span></h1>
4
+ <span class="badge status-<%= conversation.status %>">
5
+ <%= t("livechat.statuses.#{conversation.status}", default: conversation.status.humanize) %>
6
+ </span>
7
+ <% if conversation.open? %>
8
+ <%= button_to t('livechat.dashboard.resolve', default: 'Resolve'),
9
+ resolve_conversation_path(conversation) %>
10
+ <% else %>
11
+ <%= button_to t('livechat.dashboard.reopen', default: 'Reopen'),
12
+ reopen_conversation_path(conversation) %>
13
+ <% end %>
14
+ </div>
15
+
16
+ <%# One quiet line of live context: who to email, the language to answer in,
17
+ and the page of their LAST message (refreshed on every visitor write). %>
18
+ <p class="muted context-line">
19
+ <% context = [] %>
20
+ <% context << mail_to(conversation.visitor_email) if conversation.visitor_email.present? %>
21
+ <% context << conversation.locale if conversation.locale.present? %>
22
+ <% if conversation.page_url.present? %>
23
+ <% context << link_to(conversation.page_url.sub(%r{\Ahttps?://}, ''), conversation.page_url,
24
+ target: '_blank', rel: 'noopener') %>
25
+ <% end %>
26
+ <%= safe_join(context, ' · ') %>
27
+ </p>
28
+
29
+ <div class="card">
30
+ <% last = messages.last %>
31
+ <% cable = livechat_cable_data("livechat:conversation:#{conversation.id}") %>
32
+ <div class="thread" id="thread"
33
+ data-poll-url="<%= poll_conversation_path(conversation) %>"
34
+ data-typing-url="<%= typing_conversation_path(conversation) %>"
35
+ data-typing-label="<%= t('livechat.dashboard.visitor_typing', default: 'Visitor is typing...') %>"
36
+ data-latest="<%= last&.id.to_i %>"
37
+ data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
38
+ <%= tag.attributes(data: cable) if cable.any? %>>
39
+ <% previous_author = nil %>
40
+ <% messages.each do |message| %>
41
+ <% if message.system? %>
42
+ <p class="system" id="message-<%= message.id %>">
43
+ <%= t("livechat.events.#{message.event}", agent: message.agent_label,
44
+ default: "%{agent} #{message.event} the conversation") %>
45
+ · <%= message.created_at.to_fs(:short) %>
46
+ </p>
47
+ <% previous_author = nil %>
48
+ <% else %>
49
+ <% author_key = "#{message.author_type}:#{message.agent_label}" %>
50
+ <% if author_key != previous_author %>
51
+ <p class="who <%= message.author_type %>">
52
+ <%= message.agent? ? message.agent_label : conversation.display_name %>
53
+ · <%= message.created_at.to_fs(:short) %>
54
+ </p>
55
+ <% previous_author = author_key %>
56
+ <% end %>
57
+ <div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %><%= livechat_message_attachments(message) %></div>
58
+ <% end %>
59
+ <% end %>
60
+ </div>
61
+
62
+ <%# One bordered composer box (matches the visitor widget): the reply on top,
63
+ a toolbar row below with tools on the left and send on the right. %>
64
+ <%= form_with url: conversation_messages_path(conversation), method: :post,
65
+ multipart: true, class: 'reply',
66
+ data: {
67
+ record_audio: t('livechat.dashboard.record_audio', default: 'Record audio'),
68
+ stop_recording: t('livechat.dashboard.stop_recording', default: 'Stop recording'),
69
+ cancel_recording: t('livechat.dashboard.cancel_recording', default: 'Cancel recording'),
70
+ recording: t('livechat.dashboard.recording', default: 'Recording %{time}'),
71
+ microphone_unsupported: t('livechat.dashboard.microphone_unsupported', default: 'Audio recording is not supported in this browser.'),
72
+ microphone_denied: t('livechat.dashboard.microphone_denied', default: 'Microphone access was blocked. Allow microphone access in your browser and try again.'),
73
+ microphone_error: t('livechat.dashboard.microphone_error', default: 'Could not start audio recording.')
74
+ } do |form| %>
75
+ <%= form.text_area :body, rows: 2, autofocus: local_assigns.fetch(:autofocus, true),
76
+ required: !Livechat.attachments_enabled?,
77
+ placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
78
+ <%# dashboard.js hides this and swaps in a paperclip button (into .reply-tools)
79
+ plus file chips at the top of the box. Left plain as a no-JS fallback. %>
80
+ <% if Livechat.attachments_enabled? %>
81
+ <%= form.file_field :files, multiple: true, class: 'reply-file',
82
+ aria: { label: t('livechat.dashboard.attach', default: 'Attach files') } %>
83
+ <% end %>
84
+ <div class="reply-toolbar">
85
+ <div class="reply-tools"></div>
86
+ <button type="submit" class="send"
87
+ aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
88
+ title="<%= t('livechat.dashboard.send', default: 'Send') %>">
89
+ <%# Paper airplane (Heroicons, MIT) — same icon as the widget. %>
90
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"/></svg>
91
+ </button>
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+ </div>
@@ -1,88 +1,105 @@
1
+ <% content_for :body_class, 'lvc-inbox' %>
2
+
1
3
  <h1><%= t('livechat.dashboard.title', default: 'Chat') %></h1>
2
4
 
3
- <div class="tabs">
4
- <% Livechat::Conversation::STATUSES.each do |status| %>
5
- <%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
6
- <%= t("livechat.statuses.#{status}", default: status.humanize) %>
7
- <span class="count"><%= @counts.fetch(status, 0) %></span>
8
- <% end %>
9
- <% end %>
10
- </div>
5
+ <div class="inbox-shell <%= 'has-selected' if @selected_conversation %>">
6
+ <aside class="inbox-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
7
+ <div class="tabs">
8
+ <% Livechat::Conversation::STATUSES.each do |status| %>
9
+ <%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
10
+ <%= t("livechat.statuses.#{status}", default: status.humanize) %>
11
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
12
+ <% end %>
13
+ <% end %>
14
+ </div>
11
15
 
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 %>
16
+ <%= form_with url: conversations_path, method: :get, class: 'filters' do %>
17
+ <input type="hidden" name="status" value="<%= @status %>">
18
+ <input type="search" name="q" value="<%= @query %>"
19
+ placeholder="<%= t('livechat.dashboard.search', default: 'Search') %>"
20
+ aria-label="<%= t('livechat.dashboard.search', default: 'Search') %>">
21
+ <button type="submit"><%= t('livechat.dashboard.search', default: 'Search') %></button>
22
+ <% end %>
19
23
 
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
- <% cable = livechat_cable_data('livechat:inbox') %>
23
- <div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
24
- data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
25
- @counts.fetch('open', 0)].join('-') %>"
26
- <%= tag.attributes(data: cable) if cable.any? %>></div>
24
+ <%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
25
+ unless the agent is mid-search or mid-reply, whose typing must never be eaten. %>
26
+ <% cable = livechat_cable_data('livechat:inbox') %>
27
+ <div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
28
+ data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
29
+ @counts.fetch('open', 0)].join('-') %>"
30
+ <%= tag.attributes(data: cable) if cable.any? %>></div>
27
31
 
28
- <div class="card">
29
- <% if @conversations.empty? %>
30
- <p class="empty"><%= t('livechat.dashboard.empty',
31
- default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
32
- <% else %>
33
- <table>
34
- <thead>
35
- <tr>
36
- <th>#</th>
37
- <th><%= t('livechat.dashboard.visitor', default: 'Visitor') %></th>
38
- <th><%= t('livechat.dashboard.last_message', default: 'Last message') %></th>
39
- <th><%= t('livechat.dashboard.agents', default: 'Agents') %></th>
40
- <th><%= t('livechat.dashboard.updated', default: 'Updated') %></th>
41
- </tr>
42
- </thead>
43
- <tbody>
32
+ <div class="conversation-list">
33
+ <% if @conversations.empty? %>
34
+ <p class="empty"><%= t('livechat.dashboard.empty',
35
+ default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
36
+ <% else %>
44
37
  <% @conversations.each do |conversation| %>
45
38
  <% unread = @unread.fetch(conversation.id, 0) %>
46
- <tr class="<%= 'unread' if unread.positive? %>">
47
- <td class="muted"><%= link_to "##{conversation.id}", conversation_path(conversation), class: 'case-id' %></td>
48
- <td>
49
- <%= link_to conversation.display_name, conversation_path(conversation) %>
39
+ <%= link_to conversations_path(status: @status, q: @query, offset: @offset,
40
+ conversation_id: conversation.id),
41
+ class: ['conversation-row',
42
+ ('active' if @selected_conversation&.id == conversation.id),
43
+ ('unread' if unread.positive?)].compact do %>
44
+ <span class="conversation-main">
45
+ <span class="conversation-topline">
46
+ <span class="conversation-name"><%= conversation.display_name %></span>
47
+ <span class="muted conversation-time">
48
+ <% if conversation.last_activity_at %>
49
+ <%= time_ago_in_words(conversation.last_activity_at) %>
50
+ <% end %>
51
+ </span>
52
+ </span>
53
+ <span class="muted conversation-meta">
54
+ <span class="case-id">#<%= conversation.id %></span>
55
+ <% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
56
+ <span><%= conversation.visitor_email %></span>
57
+ <% end %>
58
+ </span>
59
+ <span class="muted conversation-preview"><%= conversation.last_message_preview %></span>
60
+ </span>
61
+ <span class="conversation-side">
50
62
  <% if unread.positive? %>
51
63
  <span class="badge unread-count"><%= unread %></span>
52
64
  <% end %>
53
- <% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
54
- <div class="muted"><%= conversation.visitor_email %></div>
55
- <% end %>
56
- </td>
57
- <td class="muted"><%= conversation.last_message_preview %></td>
58
- <td>
59
65
  <span class="avatars">
60
66
  <% @agents.fetch(conversation.id, []).each do |label| %>
61
67
  <%= livechat_agent_avatar(label) %>
62
68
  <% end %>
63
69
  </span>
64
- </td>
65
- <td class="muted">
66
- <% if conversation.last_activity_at %>
67
- <%= time_ago_in_words(conversation.last_activity_at) %>
68
- <% end %>
69
- </td>
70
- </tr>
70
+ </span>
71
+ <% end %>
71
72
  <% end %>
72
- </tbody>
73
- </table>
74
- <% end %>
75
- </div>
73
+ <% end %>
74
+ </div>
75
+
76
+ <div class="pager">
77
+ <% if @offset.positive? %>
78
+ <%= link_to t('livechat.dashboard.newer', default: 'Newer'),
79
+ conversations_path(status: @status, q: @query,
80
+ offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
81
+ <% end %>
82
+ <% if @more %>
83
+ <%= link_to t('livechat.dashboard.older', default: 'Older'),
84
+ conversations_path(status: @status, q: @query,
85
+ offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
86
+ <% end %>
87
+ </div>
88
+ </aside>
76
89
 
77
- <div class="pager">
78
- <% if @offset.positive? %>
79
- <%= link_to t('livechat.dashboard.newer', default: 'Newer'),
80
- conversations_path(status: @status, q: @query,
81
- offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
82
- <% end %>
83
- <% if @more %>
84
- <%= link_to t('livechat.dashboard.older', default: 'Older'),
85
- conversations_path(status: @status, q: @query,
86
- offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
87
- <% end %>
90
+ <main class="inbox-thread" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
91
+ <% if @selected_conversation %>
92
+ <p class="mobile-back">
93
+ <%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}",
94
+ conversations_path(status: @status, q: @query, offset: @offset) %>
95
+ </p>
96
+ <%= render 'thread_panel', conversation: @selected_conversation, messages: @messages, autofocus: false %>
97
+ <% else %>
98
+ <div class="empty-state">
99
+ <h2><%= t('livechat.dashboard.select_conversation', default: 'Select a conversation') %></h2>
100
+ <p class="muted"><%= t('livechat.dashboard.select_conversation_hint',
101
+ default: 'Open a chat from the list to read context and reply.') %></p>
102
+ </div>
103
+ <% end %>
104
+ </main>
88
105
  </div>
@@ -5,95 +5,4 @@
5
5
  <%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}", conversations_path %>
6
6
  </p>
7
7
 
8
- <div class="head-row">
9
- <h1><%= @conversation.display_name %> <span class="case-id">#<%= @conversation.id %></span></h1>
10
- <span class="badge status-<%= @conversation.status %>">
11
- <%= t("livechat.statuses.#{@conversation.status}", default: @conversation.status.humanize) %>
12
- </span>
13
- <% if @conversation.open? %>
14
- <%= button_to t('livechat.dashboard.resolve', default: 'Resolve'),
15
- resolve_conversation_path(@conversation) %>
16
- <% else %>
17
- <%= button_to t('livechat.dashboard.reopen', default: 'Reopen'),
18
- reopen_conversation_path(@conversation) %>
19
- <% end %>
20
- </div>
21
-
22
- <%# One quiet line of live context: who to email, the language to answer in,
23
- and the page of their LAST message (refreshed on every visitor write). %>
24
- <p class="muted context-line">
25
- <% context = [] %>
26
- <% context << mail_to(@conversation.visitor_email) if @conversation.visitor_email.present? %>
27
- <% context << @conversation.locale if @conversation.locale.present? %>
28
- <% if @conversation.page_url.present? %>
29
- <% context << link_to(@conversation.page_url.sub(%r{\Ahttps?://}, ''), @conversation.page_url,
30
- target: '_blank', rel: 'noopener') %>
31
- <% end %>
32
- <%= safe_join(context, ' · ') %>
33
- </p>
34
-
35
- <div class="card">
36
- <% last = @messages.last %>
37
- <% cable = livechat_cable_data("livechat:conversation:#{@conversation.id}") %>
38
- <div class="thread" id="thread"
39
- data-poll-url="<%= poll_conversation_path(@conversation) %>"
40
- data-typing-url="<%= typing_conversation_path(@conversation) %>"
41
- data-typing-label="<%= t('livechat.dashboard.visitor_typing', default: 'Visitor is typing...') %>"
42
- data-latest="<%= last&.id.to_i %>"
43
- data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
44
- <%= tag.attributes(data: cable) if cable.any? %>>
45
- <% previous_author = nil %>
46
- <% @messages.each do |message| %>
47
- <% if message.system? %>
48
- <p class="system" id="message-<%= message.id %>">
49
- <%= t("livechat.events.#{message.event}", agent: message.agent_label,
50
- default: "%{agent} #{message.event} the conversation") %>
51
- · <%= message.created_at.to_fs(:short) %>
52
- </p>
53
- <% previous_author = nil %>
54
- <% else %>
55
- <% author_key = "#{message.author_type}:#{message.agent_label}" %>
56
- <% if author_key != previous_author %>
57
- <p class="who <%= message.author_type %>">
58
- <%= message.agent? ? message.agent_label : @conversation.display_name %>
59
- · <%= message.created_at.to_fs(:short) %>
60
- </p>
61
- <% previous_author = author_key %>
62
- <% end %>
63
- <div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %><%= livechat_message_attachments(message) %></div>
64
- <% end %>
65
- <% end %>
66
- </div>
67
-
68
- <%# One bordered composer box (matches the visitor widget): the reply on top,
69
- a toolbar row below with tools on the left and send on the right. %>
70
- <%= form_with url: conversation_messages_path(@conversation), method: :post,
71
- multipart: true, class: 'reply',
72
- data: {
73
- record_audio: t('livechat.dashboard.record_audio', default: 'Record audio'),
74
- stop_recording: t('livechat.dashboard.stop_recording', default: 'Stop recording'),
75
- cancel_recording: t('livechat.dashboard.cancel_recording', default: 'Cancel recording'),
76
- recording: t('livechat.dashboard.recording', default: 'Recording %{time}'),
77
- microphone_unsupported: t('livechat.dashboard.microphone_unsupported', default: 'Audio recording is not supported in this browser.'),
78
- microphone_denied: t('livechat.dashboard.microphone_denied', default: 'Microphone access was blocked. Allow microphone access in your browser and try again.'),
79
- microphone_error: t('livechat.dashboard.microphone_error', default: 'Could not start audio recording.')
80
- } do |form| %>
81
- <%= form.text_area :body, rows: 2, autofocus: true, required: !Livechat.attachments_enabled?,
82
- placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
83
- <%# dashboard.js hides this and swaps in a paperclip button (into .reply-tools)
84
- plus file chips at the top of the box. Left plain as a no-JS fallback. %>
85
- <% if Livechat.attachments_enabled? %>
86
- <%= form.file_field :files, multiple: true, class: 'reply-file',
87
- aria: { label: t('livechat.dashboard.attach', default: 'Attach files') } %>
88
- <% end %>
89
- <div class="reply-toolbar">
90
- <div class="reply-tools"></div>
91
- <button type="submit" class="send"
92
- aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
93
- title="<%= t('livechat.dashboard.send', default: 'Send') %>">
94
- <%# Paper airplane (Heroicons, MIT) — same icon as the widget. %>
95
- <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"/></svg>
96
- </button>
97
- </div>
98
- <% end %>
99
- </div>
8
+ <%= render 'thread_panel', conversation: @conversation, messages: @messages %>
@@ -301,12 +301,17 @@
301
301
  var token = index.getAttribute("data-token");
302
302
  var url = index.getAttribute("data-poll-url");
303
303
  var searchBox = document.querySelector(".filters input[type=search]");
304
+ var replyBox = document.querySelector(".reply textarea");
304
305
 
305
306
  function check() {
306
307
  if (document.hidden) return;
307
308
  // Never reload out from under an agent who is typing a search.
308
309
  if (searchBox && searchBox.value !== searchBox.defaultValue) return;
309
310
  if (searchBox && document.activeElement === searchBox) return;
311
+ // On the two-column inbox, the selected thread lives beside the list.
312
+ // Let thread polling append messages while a reply draft is active.
313
+ if (replyBox && replyBox.value.trim()) return;
314
+ if (replyBox && document.activeElement === replyBox) return;
310
315
  fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
311
316
  .then(function (response) { return response.ok ? response.json() : null; })
312
317
  .then(function (data) {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.6.0'
4
+ VERSION = '0.6.2'
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.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -55,6 +55,7 @@ files:
55
55
  - app/models/livechat/conversation.rb
56
56
  - app/models/livechat/message.rb
57
57
  - app/views/layouts/livechat/application.html.erb
58
+ - app/views/livechat/conversations/_thread_panel.html.erb
58
59
  - app/views/livechat/conversations/index.html.erb
59
60
  - app/views/livechat/conversations/show.html.erb
60
61
  - app/views/livechat/mailer/new_agent_reply.text.erb