livechat 0.6.1 → 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: 89fb2589ac2b07d722d95c48bf0de22dcdcaa21ebcc8e691ffa4a3eb862fcd3d
4
- data.tar.gz: cbb878a7a0c0794459468f3f857473ad1f29b6b92a477572be99797166eeefcf
3
+ metadata.gz: ebf70396ba82be98cc6f9332c276c5314a14b3ba4330f99646505ce1dd293e7e
4
+ data.tar.gz: f48e3578174194d4a501cfc2fd30a7f5772e58b8b9a79399c46073eb4bdac003
5
5
  SHA512:
6
- metadata.gz: b9fc7e79282e70099732ac379632844d12601b61c438598f39551ed3fca91e635ce424c97786afd51ecdd18a814f10bab3c14d6fc53ad3f6bda54c22eeb78688
7
- data.tar.gz: 14812aa4533f89bf621688036929e0d6bb9e13d2e9bef4339d59c83a62300ed3cefe5f382f928d8d91ab46cfc084a8a47e467adfa15d0ee43921c9cedf238264
6
+ metadata.gz: ef83bc9a0d405fc0aa5ad055a12243e83a0a2d35d1f9ed058c68b27381c9965575747372992f69fc87fe70a909e8b06f44a7b31df410216e758a7a5aed9c0910
7
+ data.tar.gz: f77398569393337ae0cf9f023bb887c3ac0d15aa07b34b05669d98af643124f949a41b4d77089b14b30a116e9cfec6fb350689a9d59316f20f5353991bc7aeca
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 0.6.1
4
13
 
5
14
  - Repositioned the docs around in-app support messaging for Rails: users ask
data/README.md CHANGED
@@ -90,14 +90,15 @@ fallback.
90
90
 
91
91
  ## The inbox
92
92
 
93
- Open and resolved tabs, unread badges, search (visitor name, email, and
94
- everything anyone wrote), and a column showing which teammates have worked each
95
- thread. Reply with Cmd/Ctrl+Enter, resolve, reopen. Both pages keep themselves
96
- 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.
97
98
 
98
- | The list | The thread |
99
+ | Desktop inbox | Mobile thread |
99
100
  | --- | --- |
100
- | ![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) |
101
102
 
102
103
  Every reply carries its author and time, and resolving is recorded in the
103
104
  thread. Gated by `authorize_agent`.
@@ -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
@@ -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.1'
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.1
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