livechat 0.6.1 → 0.6.3
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 +15 -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 +89 -0
- data/app/views/livechat/conversations/_thread_panel.html.erb +102 -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: 73bea8a61c28ee7ab2edd008d312343e3373f1737123578fac9935bc7b639ad0
|
|
4
|
+
data.tar.gz: 75f34e42994adde044ac3bbae1325b4e1997e46c6929bd182a0ca5b87cc1f6bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37ffa3aa93aabfd29e2cbeefb28c1c527be13cb7744650a118209d04504388289ae79c44282414667794c756c737b234ef9dee4adac6579d6159f312aaf14847
|
|
7
|
+
data.tar.gz: 7f2b06392e757eefb86b1b7cc729aae38721a210289eebfee584c9b8727a4d7dca114390afe4eb7a7e5482b2f358241bb0246299a80a0a51fc1976b1a1913ee0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3
|
|
4
|
+
|
|
5
|
+
- Replaced the selected thread's separate status badge and resolve/reopen
|
|
6
|
+
button with a compact Open/Resolved status toggle.
|
|
7
|
+
- Refreshed the README inbox screenshots to show the new status toggle.
|
|
8
|
+
|
|
9
|
+
## 0.6.2
|
|
10
|
+
|
|
11
|
+
- Reworked the inbox into a two-column desktop layout: conversation list on the
|
|
12
|
+
left, selected thread on the right, with live message polling preserved.
|
|
13
|
+
- Improved the conversation list with compact rows showing the visitor, last
|
|
14
|
+
message preview, unread count, relative activity time, and participating
|
|
15
|
+
agent avatars.
|
|
16
|
+
- Kept mobile as a focused single-pane inbox/thread flow.
|
|
17
|
+
|
|
3
18
|
## 0.6.1
|
|
4
19
|
|
|
5
20
|
- 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
|
|
@@ -61,6 +61,20 @@
|
|
|
61
61
|
.head-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
62
62
|
.head-row h1 { margin: 0; flex: 1; }
|
|
63
63
|
.head-row form { display: inline; }
|
|
64
|
+
.status-toggle-form { flex: 0 0 auto; }
|
|
65
|
+
.status-toggle { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 176px;
|
|
66
|
+
min-height: 36px; padding: 3px; border-radius: 999px; border: 1px solid var(--border);
|
|
67
|
+
background: var(--bg); color: var(--muted); overflow: hidden; }
|
|
68
|
+
.status-toggle::before { content: ""; position: absolute; z-index: 0; top: 3px; bottom: 3px;
|
|
69
|
+
width: calc(50% - 3px); border-radius: 999px; background: var(--surface);
|
|
70
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, .12); transition: left .16s ease; }
|
|
71
|
+
.status-toggle.is-open::before { left: 3px; }
|
|
72
|
+
.status-toggle.is-resolved::before { left: 50%; }
|
|
73
|
+
.status-toggle span { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center;
|
|
74
|
+
min-width: 0; padding: 0 10px; font-size: 13px; font-weight: 700; white-space: nowrap; }
|
|
75
|
+
.status-toggle.is-open span:first-child { color: var(--open); }
|
|
76
|
+
.status-toggle.is-resolved span:last-child { color: var(--text); }
|
|
77
|
+
.status-toggle:hover { text-decoration: none; color: var(--text); }
|
|
64
78
|
button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
|
65
79
|
background: var(--surface); color: var(--text); font: inherit; }
|
|
66
80
|
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
@@ -142,6 +156,60 @@
|
|
|
142
156
|
.reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%;
|
|
143
157
|
background: var(--accent); color: var(--accent-text); cursor: pointer;
|
|
144
158
|
display: flex; align-items: center; justify-content: center; }
|
|
159
|
+
/* Desktop inbox: a durable conversation list on the left and the active
|
|
160
|
+
thread on the right, with each pane owning its own scroll. */
|
|
161
|
+
.lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
|
|
162
|
+
.lvc-inbox { overflow: hidden; }
|
|
163
|
+
.lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
|
|
164
|
+
.lvc-inbox .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
|
|
165
|
+
.inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr;
|
|
166
|
+
gap: 16px; }
|
|
167
|
+
.inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
|
168
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
169
|
+
border-radius: 8px; overflow: hidden; }
|
|
170
|
+
.inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
|
|
171
|
+
border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
|
|
172
|
+
.inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
|
|
173
|
+
justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px;
|
|
174
|
+
border-radius: 8px; color: var(--muted); font-weight: 600; }
|
|
175
|
+
.inbox-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
|
|
176
|
+
.inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0;
|
|
177
|
+
height: 2px; border-radius: 999px; background: var(--accent); }
|
|
178
|
+
.inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
|
|
179
|
+
.inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
|
|
180
|
+
.inbox-sidebar .tabs a.active .count { background: var(--border); }
|
|
181
|
+
.inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
|
|
182
|
+
.inbox-sidebar .filters input[type=search] { max-width: none; }
|
|
183
|
+
.conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
184
|
+
.conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
|
|
185
|
+
color: var(--text); border-bottom: 1px solid var(--border); }
|
|
186
|
+
.conversation-row:hover { text-decoration: none; background: var(--bg); }
|
|
187
|
+
.conversation-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
|
|
188
|
+
box-shadow: inset 3px 0 0 var(--accent); }
|
|
189
|
+
.conversation-row.unread .conversation-name, .conversation-row.unread .conversation-preview { font-weight: 700; }
|
|
190
|
+
.conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
191
|
+
.conversation-topline, .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
192
|
+
.conversation-name, .conversation-preview, .conversation-meta span { overflow: hidden; text-overflow: ellipsis;
|
|
193
|
+
white-space: nowrap; }
|
|
194
|
+
.conversation-name { font-weight: 600; }
|
|
195
|
+
.conversation-time { margin-left: auto; white-space: nowrap; }
|
|
196
|
+
.conversation-preview { display: block; }
|
|
197
|
+
.conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
|
|
198
|
+
gap: 8px; padding-top: 1px; }
|
|
199
|
+
.conversation-side .avatars { min-height: 26px; }
|
|
200
|
+
.inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
|
|
201
|
+
.inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
202
|
+
.inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
|
|
203
|
+
padding: 0; }
|
|
204
|
+
.conversation-panel .head-row, .conversation-panel .context-line { flex: 0 0 auto; }
|
|
205
|
+
.conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
|
|
206
|
+
border-radius: 8px; }
|
|
207
|
+
.conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
208
|
+
.conversation-panel .reply { flex: 0 0 auto; }
|
|
209
|
+
.empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
|
|
210
|
+
.empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
211
|
+
.empty-state p { margin: 0; }
|
|
212
|
+
.mobile-back { display: none; }
|
|
145
213
|
/* Conversation page: fill the viewport and scroll the THREAD, not the
|
|
146
214
|
page — so a reply never jumps the page, and the composer stays put. */
|
|
147
215
|
.lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
|
|
@@ -152,6 +220,27 @@
|
|
|
152
220
|
.lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
|
153
221
|
.lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
154
222
|
.lvc-convo .reply { flex: 0 0 auto; }
|
|
223
|
+
@media (max-width: 760px) {
|
|
224
|
+
body { font-size: 14px; }
|
|
225
|
+
.container { padding: 14px 10px 24px; }
|
|
226
|
+
.lvc-inbox { overflow: auto; }
|
|
227
|
+
.lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
|
|
228
|
+
.lvc-inbox .container { padding-bottom: 10px; }
|
|
229
|
+
.inbox-shell { display: block; min-height: calc(100vh - 62px); }
|
|
230
|
+
.inbox-sidebar, .inbox-thread { min-height: calc(100vh - 62px); }
|
|
231
|
+
.inbox-shell.has-selected .inbox-sidebar { display: none; }
|
|
232
|
+
.inbox-shell:not(.has-selected) .inbox-thread { display: none; }
|
|
233
|
+
.conversation-list { overflow: visible; }
|
|
234
|
+
.conversation-row { padding: 12px; }
|
|
235
|
+
.conversation-meta { display: none; }
|
|
236
|
+
.conversation-panel { min-height: calc(100vh - 64px); }
|
|
237
|
+
.inbox-thread .conversation-panel { padding: 0; }
|
|
238
|
+
.mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
239
|
+
.conversation-panel .head-row h1 { font-size: 18px; }
|
|
240
|
+
.status-toggle { width: 152px; min-height: 34px; }
|
|
241
|
+
.status-toggle span { padding: 0 7px; font-size: 12px; }
|
|
242
|
+
.thread .msg { max-width: 88%; }
|
|
243
|
+
}
|
|
155
244
|
</style>
|
|
156
245
|
</head>
|
|
157
246
|
<body class="<%= yield(:body_class) %>">
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<div class="conversation-panel">
|
|
2
|
+
<div class="head-row">
|
|
3
|
+
<h1><%= conversation.display_name %> <span class="case-id">#<%= conversation.id %></span></h1>
|
|
4
|
+
<% if conversation.open? %>
|
|
5
|
+
<%= button_to resolve_conversation_path(conversation), method: :post,
|
|
6
|
+
form_class: 'status-toggle-form',
|
|
7
|
+
class: 'status-toggle is-open',
|
|
8
|
+
aria: { label: t('livechat.dashboard.resolve', default: 'Resolve') } do %>
|
|
9
|
+
<span><%= t('livechat.statuses.open', default: 'Open') %></span>
|
|
10
|
+
<span><%= t('livechat.statuses.resolved', default: 'Resolved') %></span>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<%= button_to reopen_conversation_path(conversation), method: :post,
|
|
14
|
+
form_class: 'status-toggle-form',
|
|
15
|
+
class: 'status-toggle is-resolved',
|
|
16
|
+
aria: { label: t('livechat.dashboard.reopen', default: 'Reopen') } do %>
|
|
17
|
+
<span><%= t('livechat.statuses.open', default: 'Open') %></span>
|
|
18
|
+
<span><%= t('livechat.statuses.resolved', default: 'Resolved') %></span>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%# One quiet line of live context: who to email, the language to answer in,
|
|
24
|
+
and the page of their LAST message (refreshed on every visitor write). %>
|
|
25
|
+
<p class="muted context-line">
|
|
26
|
+
<% context = [] %>
|
|
27
|
+
<% context << mail_to(conversation.visitor_email) if conversation.visitor_email.present? %>
|
|
28
|
+
<% context << conversation.locale if conversation.locale.present? %>
|
|
29
|
+
<% if conversation.page_url.present? %>
|
|
30
|
+
<% context << link_to(conversation.page_url.sub(%r{\Ahttps?://}, ''), conversation.page_url,
|
|
31
|
+
target: '_blank', rel: 'noopener') %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<%= safe_join(context, ' · ') %>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<div class="card">
|
|
37
|
+
<% last = messages.last %>
|
|
38
|
+
<% cable = livechat_cable_data("livechat:conversation:#{conversation.id}") %>
|
|
39
|
+
<div class="thread" id="thread"
|
|
40
|
+
data-poll-url="<%= poll_conversation_path(conversation) %>"
|
|
41
|
+
data-typing-url="<%= typing_conversation_path(conversation) %>"
|
|
42
|
+
data-typing-label="<%= t('livechat.dashboard.visitor_typing', default: 'Visitor is typing...') %>"
|
|
43
|
+
data-latest="<%= last&.id.to_i %>"
|
|
44
|
+
data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
|
|
45
|
+
<%= tag.attributes(data: cable) if cable.any? %>>
|
|
46
|
+
<% previous_author = nil %>
|
|
47
|
+
<% messages.each do |message| %>
|
|
48
|
+
<% if message.system? %>
|
|
49
|
+
<p class="system" id="message-<%= message.id %>">
|
|
50
|
+
<%= t("livechat.events.#{message.event}", agent: message.agent_label,
|
|
51
|
+
default: "%{agent} #{message.event} the conversation") %>
|
|
52
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
53
|
+
</p>
|
|
54
|
+
<% previous_author = nil %>
|
|
55
|
+
<% else %>
|
|
56
|
+
<% author_key = "#{message.author_type}:#{message.agent_label}" %>
|
|
57
|
+
<% if author_key != previous_author %>
|
|
58
|
+
<p class="who <%= message.author_type %>">
|
|
59
|
+
<%= message.agent? ? message.agent_label : conversation.display_name %>
|
|
60
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
61
|
+
</p>
|
|
62
|
+
<% previous_author = author_key %>
|
|
63
|
+
<% end %>
|
|
64
|
+
<div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %><%= livechat_message_attachments(message) %></div>
|
|
65
|
+
<% end %>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<%# One bordered composer box (matches the visitor widget): the reply on top,
|
|
70
|
+
a toolbar row below with tools on the left and send on the right. %>
|
|
71
|
+
<%= form_with url: conversation_messages_path(conversation), method: :post,
|
|
72
|
+
multipart: true, class: 'reply',
|
|
73
|
+
data: {
|
|
74
|
+
record_audio: t('livechat.dashboard.record_audio', default: 'Record audio'),
|
|
75
|
+
stop_recording: t('livechat.dashboard.stop_recording', default: 'Stop recording'),
|
|
76
|
+
cancel_recording: t('livechat.dashboard.cancel_recording', default: 'Cancel recording'),
|
|
77
|
+
recording: t('livechat.dashboard.recording', default: 'Recording %{time}'),
|
|
78
|
+
microphone_unsupported: t('livechat.dashboard.microphone_unsupported', default: 'Audio recording is not supported in this browser.'),
|
|
79
|
+
microphone_denied: t('livechat.dashboard.microphone_denied', default: 'Microphone access was blocked. Allow microphone access in your browser and try again.'),
|
|
80
|
+
microphone_error: t('livechat.dashboard.microphone_error', default: 'Could not start audio recording.')
|
|
81
|
+
} do |form| %>
|
|
82
|
+
<%= form.text_area :body, rows: 2, autofocus: local_assigns.fetch(:autofocus, true),
|
|
83
|
+
required: !Livechat.attachments_enabled?,
|
|
84
|
+
placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
|
|
85
|
+
<%# dashboard.js hides this and swaps in a paperclip button (into .reply-tools)
|
|
86
|
+
plus file chips at the top of the box. Left plain as a no-JS fallback. %>
|
|
87
|
+
<% if Livechat.attachments_enabled? %>
|
|
88
|
+
<%= form.file_field :files, multiple: true, class: 'reply-file',
|
|
89
|
+
aria: { label: t('livechat.dashboard.attach', default: 'Attach files') } %>
|
|
90
|
+
<% end %>
|
|
91
|
+
<div class="reply-toolbar">
|
|
92
|
+
<div class="reply-tools"></div>
|
|
93
|
+
<button type="submit" class="send"
|
|
94
|
+
aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
|
|
95
|
+
title="<%= t('livechat.dashboard.send', default: 'Send') %>">
|
|
96
|
+
<%# Paper airplane (Heroicons, MIT) — same icon as the widget. %>
|
|
97
|
+
<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>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
</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.3
|
|
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
|