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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +7 -6
- data/app/controllers/livechat/conversations_controller.rb +10 -2
- data/app/controllers/livechat/messages_controller.rb +2 -2
- data/app/views/layouts/livechat/application.html.erb +73 -0
- data/app/views/livechat/conversations/_thread_panel.html.erb +95 -0
- data/app/views/livechat/conversations/index.html.erb +87 -70
- data/app/views/livechat/conversations/show.html.erb +1 -92
- data/lib/livechat/dashboard.js +5 -0
- data/lib/livechat/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ebf70396ba82be98cc6f9332c276c5314a14b3ba4330f99646505ce1dd293e7e
|
|
4
|
+
data.tar.gz: f48e3578174194d4a501cfc2fd30a7f5772e58b8b9a79399c46073eb4bdac003
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
94
|
-
everything anyone wrote), and a column
|
|
95
|
-
thread. Reply with Cmd/Ctrl+Enter,
|
|
96
|
-
fresh while you watch — and never
|
|
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
|
-
|
|
|
99
|
+
| Desktop inbox | Mobile thread |
|
|
99
100
|
| --- | --- |
|
|
100
|
-
|  |  |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
redirect_back fallback_location: conversation_path(conversation, anchor: "message-#{message.id}")
|
|
18
18
|
rescue ActiveRecord::RecordInvalid => e
|
|
19
|
-
|
|
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="
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
<% cable = livechat_cable_data('livechat:inbox') %>
|
|
23
|
-
<div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
</
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
</
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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 %>
|
data/lib/livechat/dashboard.js
CHANGED
|
@@ -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) {
|
data/lib/livechat/version.rb
CHANGED
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.
|
|
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
|