livechat 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +38 -9
- data/app/controllers/livechat/conversations_controller.rb +10 -2
- data/app/controllers/livechat/messages_controller.rb +2 -2
- data/app/helpers/livechat/widget_helper.rb +8 -3
- 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,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2
|
|
4
|
+
|
|
5
|
+
- Reworked the inbox into a two-column desktop layout: conversation list on the
|
|
6
|
+
left, selected thread on the right, with live message polling preserved.
|
|
7
|
+
- Improved the conversation list with compact rows showing the visitor, last
|
|
8
|
+
message preview, unread count, relative activity time, and participating
|
|
9
|
+
agent avatars.
|
|
10
|
+
- Kept mobile as a focused single-pane inbox/thread flow.
|
|
11
|
+
|
|
12
|
+
## 0.6.1
|
|
13
|
+
|
|
14
|
+
- Repositioned the docs around in-app support messaging for Rails: users ask
|
|
15
|
+
for help from the page where they got stuck, and teams answer from the
|
|
16
|
+
mounted inbox without SaaS, third-party scripts, or a separate support app.
|
|
17
|
+
- Added `message:` to `livechat_button`, so contextual support buttons can open
|
|
18
|
+
the widget and prefill the composer in one helper call.
|
|
19
|
+
- Updated the PRD to match the current shipped feature set through 0.6.0.
|
|
20
|
+
|
|
3
21
|
## 0.6.0
|
|
4
22
|
|
|
5
23
|
- Improved unread notification emails. Team emails now summarize the unread
|
data/README.md
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
[](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
|
|
6
6
|
[](MIT-LICENSE)
|
|
7
7
|
|
|
8
|
-
**
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
**In-app support messaging for Rails.** Add a chat button so users can ask for
|
|
9
|
+
help from the page where they got stuck. Your team answers from a mounted inbox.
|
|
10
|
+
No SaaS account, no third-party script, no separate app, no customer data leaving
|
|
11
|
+
your database.
|
|
12
|
+
|
|
13
|
+
Use it when email is too detached, Intercom is too much, and Chatwoot is another
|
|
14
|
+
app you do not want to deploy.
|
|
11
15
|
|
|
12
16
|

|
|
13
17
|
|
|
@@ -86,14 +90,15 @@ fallback.
|
|
|
86
90
|
|
|
87
91
|
## The inbox
|
|
88
92
|
|
|
89
|
-
Open and resolved
|
|
90
|
-
everything anyone wrote), and a column
|
|
91
|
-
thread. Reply with Cmd/Ctrl+Enter,
|
|
92
|
-
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.
|
|
93
98
|
|
|
94
|
-
|
|
|
99
|
+
| Desktop inbox | Mobile thread |
|
|
95
100
|
| --- | --- |
|
|
96
|
-
|  |  |
|
|
97
102
|
|
|
98
103
|
Every reply carries its author and time, and resolving is recorded in the
|
|
99
104
|
thread. Gated by `authorize_agent`.
|
|
@@ -232,11 +237,35 @@ to a stream the server signed for it. Under a strict CSP, allow the socket with
|
|
|
232
237
|
| `data-livechat-open` | Any element opens the panel on click |
|
|
233
238
|
| `data-livechat-message="…"` | Prefill from that element — great for contextual buttons |
|
|
234
239
|
| `<%= livechat_button %>` | A plain, unstyled opener button |
|
|
240
|
+
| `<%= livechat_button("Ask about this order", message: "I need help with order ##{@order.id}") %>` | Open and prefill in one helper |
|
|
235
241
|
| `config.show_launcher = false` | Hide the bubble entirely |
|
|
236
242
|
|
|
237
243
|
While replies are unread, every `data-livechat-open` element carries a small
|
|
238
244
|
count badge — so hiding the launcher never hides the answer.
|
|
239
245
|
|
|
246
|
+
Contextual buttons are where a Rails app beats a generic support widget:
|
|
247
|
+
|
|
248
|
+
```erb
|
|
249
|
+
<%= livechat_button("Ask about this order",
|
|
250
|
+
message: "I need help with order ##{@order.id}") %>
|
|
251
|
+
|
|
252
|
+
<%= livechat_button("Ask about this invoice",
|
|
253
|
+
message: "I need help with invoice ##{@invoice.number}") %>
|
|
254
|
+
|
|
255
|
+
<button data-livechat-open
|
|
256
|
+
data-livechat-message="I need help with project <%= @project.name %>">
|
|
257
|
+
Contact support
|
|
258
|
+
</button>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Use cases
|
|
262
|
+
|
|
263
|
+
- SaaS apps: answer billing, onboarding and account questions from inside the app.
|
|
264
|
+
- Customer portals: let users ask about orders, invoices, documents or bookings.
|
|
265
|
+
- Marketplaces: keep buyer, seller and admin support tied to the current page.
|
|
266
|
+
- Internal tools: give non-technical teammates a direct line from admin screens.
|
|
267
|
+
- Course and member apps: handle access, lesson and subscription questions in context.
|
|
268
|
+
|
|
240
269
|
## What it doesn't do
|
|
241
270
|
|
|
242
271
|
No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no
|
|
@@ -23,6 +23,14 @@ module Livechat
|
|
|
23
23
|
@more = page.size > PER_PAGE
|
|
24
24
|
@conversations = page.first(PER_PAGE)
|
|
25
25
|
|
|
26
|
+
if params[:conversation_id].present?
|
|
27
|
+
@selected_conversation = Conversation.find_by(id: params[:conversation_id])
|
|
28
|
+
if @selected_conversation
|
|
29
|
+
@messages = @selected_conversation.messages.chronological.to_a
|
|
30
|
+
@selected_conversation.mark_read_for_agent!
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
# Unread badges and participating agents for the whole page, one query each.
|
|
27
35
|
ids = @conversations.map(&:id)
|
|
28
36
|
@unread = Message.from_visitor.unread.where(conversation_id: ids)
|
|
@@ -70,12 +78,12 @@ module Livechat
|
|
|
70
78
|
|
|
71
79
|
def resolve
|
|
72
80
|
@conversation.resolve_by!(current_agent_label)
|
|
73
|
-
|
|
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
|
|
@@ -18,12 +18,17 @@ module Livechat
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# A plain, unstyled <button> that opens the chat — it picks up the host's
|
|
21
|
-
# own styles.
|
|
22
|
-
|
|
21
|
+
# own styles. Pass message: to prefill the composer with page-specific
|
|
22
|
+
# context. Put it anywhere on a page that also renders livechat_tag.
|
|
23
|
+
def livechat_button(label = nil, message: nil, **options)
|
|
23
24
|
return unless Livechat.enabled?(request)
|
|
24
25
|
|
|
26
|
+
label ||= options.delete(:label)
|
|
25
27
|
label ||= I18n.t(:launcher, scope: :livechat, default: 'Chat with us')
|
|
26
|
-
|
|
28
|
+
data = (options.delete(:data) { {} } || {}).merge(livechat_open: '')
|
|
29
|
+
data[:livechat_message] = message if message.present?
|
|
30
|
+
|
|
31
|
+
tag.button(label, type: 'button', data: data, **options)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -142,6 +142,60 @@
|
|
|
142
142
|
.reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%;
|
|
143
143
|
background: var(--accent); color: var(--accent-text); cursor: pointer;
|
|
144
144
|
display: flex; align-items: center; justify-content: center; }
|
|
145
|
+
/* Desktop inbox: a durable conversation list on the left and the active
|
|
146
|
+
thread on the right, with each pane owning its own scroll. */
|
|
147
|
+
.lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
|
|
148
|
+
.lvc-inbox { overflow: hidden; }
|
|
149
|
+
.lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
|
|
150
|
+
.lvc-inbox .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
|
|
151
|
+
.inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr;
|
|
152
|
+
gap: 16px; }
|
|
153
|
+
.inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
|
154
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
155
|
+
border-radius: 8px; overflow: hidden; }
|
|
156
|
+
.inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
|
|
157
|
+
border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
|
|
158
|
+
.inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
|
|
159
|
+
justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px;
|
|
160
|
+
border-radius: 8px; color: var(--muted); font-weight: 600; }
|
|
161
|
+
.inbox-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
|
|
162
|
+
.inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0;
|
|
163
|
+
height: 2px; border-radius: 999px; background: var(--accent); }
|
|
164
|
+
.inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
|
|
165
|
+
.inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
|
|
166
|
+
.inbox-sidebar .tabs a.active .count { background: var(--border); }
|
|
167
|
+
.inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
|
|
168
|
+
.inbox-sidebar .filters input[type=search] { max-width: none; }
|
|
169
|
+
.conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
170
|
+
.conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
|
|
171
|
+
color: var(--text); border-bottom: 1px solid var(--border); }
|
|
172
|
+
.conversation-row:hover { text-decoration: none; background: var(--bg); }
|
|
173
|
+
.conversation-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
|
|
174
|
+
box-shadow: inset 3px 0 0 var(--accent); }
|
|
175
|
+
.conversation-row.unread .conversation-name, .conversation-row.unread .conversation-preview { font-weight: 700; }
|
|
176
|
+
.conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
177
|
+
.conversation-topline, .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
178
|
+
.conversation-name, .conversation-preview, .conversation-meta span { overflow: hidden; text-overflow: ellipsis;
|
|
179
|
+
white-space: nowrap; }
|
|
180
|
+
.conversation-name { font-weight: 600; }
|
|
181
|
+
.conversation-time { margin-left: auto; white-space: nowrap; }
|
|
182
|
+
.conversation-preview { display: block; }
|
|
183
|
+
.conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
|
|
184
|
+
gap: 8px; padding-top: 1px; }
|
|
185
|
+
.conversation-side .avatars { min-height: 26px; }
|
|
186
|
+
.inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
|
|
187
|
+
.inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
188
|
+
.inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
|
|
189
|
+
padding: 0; }
|
|
190
|
+
.conversation-panel .head-row, .conversation-panel .context-line { flex: 0 0 auto; }
|
|
191
|
+
.conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
|
|
192
|
+
border-radius: 8px; }
|
|
193
|
+
.conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
194
|
+
.conversation-panel .reply { flex: 0 0 auto; }
|
|
195
|
+
.empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
|
|
196
|
+
.empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
197
|
+
.empty-state p { margin: 0; }
|
|
198
|
+
.mobile-back { display: none; }
|
|
145
199
|
/* Conversation page: fill the viewport and scroll the THREAD, not the
|
|
146
200
|
page — so a reply never jumps the page, and the composer stays put. */
|
|
147
201
|
.lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
|
|
@@ -152,6 +206,25 @@
|
|
|
152
206
|
.lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
|
153
207
|
.lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
154
208
|
.lvc-convo .reply { flex: 0 0 auto; }
|
|
209
|
+
@media (max-width: 760px) {
|
|
210
|
+
body { font-size: 14px; }
|
|
211
|
+
.container { padding: 14px 10px 24px; }
|
|
212
|
+
.lvc-inbox { overflow: auto; }
|
|
213
|
+
.lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
|
|
214
|
+
.lvc-inbox .container { padding-bottom: 10px; }
|
|
215
|
+
.inbox-shell { display: block; min-height: calc(100vh - 62px); }
|
|
216
|
+
.inbox-sidebar, .inbox-thread { min-height: calc(100vh - 62px); }
|
|
217
|
+
.inbox-shell.has-selected .inbox-sidebar { display: none; }
|
|
218
|
+
.inbox-shell:not(.has-selected) .inbox-thread { display: none; }
|
|
219
|
+
.conversation-list { overflow: visible; }
|
|
220
|
+
.conversation-row { padding: 12px; }
|
|
221
|
+
.conversation-meta { display: none; }
|
|
222
|
+
.conversation-panel { min-height: calc(100vh - 64px); }
|
|
223
|
+
.inbox-thread .conversation-panel { padding: 0; }
|
|
224
|
+
.mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
225
|
+
.conversation-panel .head-row h1 { font-size: 18px; }
|
|
226
|
+
.thread .msg { max-width: 88%; }
|
|
227
|
+
}
|
|
155
228
|
</style>
|
|
156
229
|
</head>
|
|
157
230
|
<body class="<%= yield(:body_class) %>">
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<div class="conversation-panel">
|
|
2
|
+
<div class="head-row">
|
|
3
|
+
<h1><%= conversation.display_name %> <span class="case-id">#<%= conversation.id %></span></h1>
|
|
4
|
+
<span class="badge status-<%= conversation.status %>">
|
|
5
|
+
<%= t("livechat.statuses.#{conversation.status}", default: conversation.status.humanize) %>
|
|
6
|
+
</span>
|
|
7
|
+
<% if conversation.open? %>
|
|
8
|
+
<%= button_to t('livechat.dashboard.resolve', default: 'Resolve'),
|
|
9
|
+
resolve_conversation_path(conversation) %>
|
|
10
|
+
<% else %>
|
|
11
|
+
<%= button_to t('livechat.dashboard.reopen', default: 'Reopen'),
|
|
12
|
+
reopen_conversation_path(conversation) %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<%# One quiet line of live context: who to email, the language to answer in,
|
|
17
|
+
and the page of their LAST message (refreshed on every visitor write). %>
|
|
18
|
+
<p class="muted context-line">
|
|
19
|
+
<% context = [] %>
|
|
20
|
+
<% context << mail_to(conversation.visitor_email) if conversation.visitor_email.present? %>
|
|
21
|
+
<% context << conversation.locale if conversation.locale.present? %>
|
|
22
|
+
<% if conversation.page_url.present? %>
|
|
23
|
+
<% context << link_to(conversation.page_url.sub(%r{\Ahttps?://}, ''), conversation.page_url,
|
|
24
|
+
target: '_blank', rel: 'noopener') %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<%= safe_join(context, ' · ') %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<div class="card">
|
|
30
|
+
<% last = messages.last %>
|
|
31
|
+
<% cable = livechat_cable_data("livechat:conversation:#{conversation.id}") %>
|
|
32
|
+
<div class="thread" id="thread"
|
|
33
|
+
data-poll-url="<%= poll_conversation_path(conversation) %>"
|
|
34
|
+
data-typing-url="<%= typing_conversation_path(conversation) %>"
|
|
35
|
+
data-typing-label="<%= t('livechat.dashboard.visitor_typing', default: 'Visitor is typing...') %>"
|
|
36
|
+
data-latest="<%= last&.id.to_i %>"
|
|
37
|
+
data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
|
|
38
|
+
<%= tag.attributes(data: cable) if cable.any? %>>
|
|
39
|
+
<% previous_author = nil %>
|
|
40
|
+
<% messages.each do |message| %>
|
|
41
|
+
<% if message.system? %>
|
|
42
|
+
<p class="system" id="message-<%= message.id %>">
|
|
43
|
+
<%= t("livechat.events.#{message.event}", agent: message.agent_label,
|
|
44
|
+
default: "%{agent} #{message.event} the conversation") %>
|
|
45
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
46
|
+
</p>
|
|
47
|
+
<% previous_author = nil %>
|
|
48
|
+
<% else %>
|
|
49
|
+
<% author_key = "#{message.author_type}:#{message.agent_label}" %>
|
|
50
|
+
<% if author_key != previous_author %>
|
|
51
|
+
<p class="who <%= message.author_type %>">
|
|
52
|
+
<%= message.agent? ? message.agent_label : conversation.display_name %>
|
|
53
|
+
· <%= message.created_at.to_fs(:short) %>
|
|
54
|
+
</p>
|
|
55
|
+
<% previous_author = author_key %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %><%= livechat_message_attachments(message) %></div>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<%# One bordered composer box (matches the visitor widget): the reply on top,
|
|
63
|
+
a toolbar row below with tools on the left and send on the right. %>
|
|
64
|
+
<%= form_with url: conversation_messages_path(conversation), method: :post,
|
|
65
|
+
multipart: true, class: 'reply',
|
|
66
|
+
data: {
|
|
67
|
+
record_audio: t('livechat.dashboard.record_audio', default: 'Record audio'),
|
|
68
|
+
stop_recording: t('livechat.dashboard.stop_recording', default: 'Stop recording'),
|
|
69
|
+
cancel_recording: t('livechat.dashboard.cancel_recording', default: 'Cancel recording'),
|
|
70
|
+
recording: t('livechat.dashboard.recording', default: 'Recording %{time}'),
|
|
71
|
+
microphone_unsupported: t('livechat.dashboard.microphone_unsupported', default: 'Audio recording is not supported in this browser.'),
|
|
72
|
+
microphone_denied: t('livechat.dashboard.microphone_denied', default: 'Microphone access was blocked. Allow microphone access in your browser and try again.'),
|
|
73
|
+
microphone_error: t('livechat.dashboard.microphone_error', default: 'Could not start audio recording.')
|
|
74
|
+
} do |form| %>
|
|
75
|
+
<%= form.text_area :body, rows: 2, autofocus: local_assigns.fetch(:autofocus, true),
|
|
76
|
+
required: !Livechat.attachments_enabled?,
|
|
77
|
+
placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
|
|
78
|
+
<%# dashboard.js hides this and swaps in a paperclip button (into .reply-tools)
|
|
79
|
+
plus file chips at the top of the box. Left plain as a no-JS fallback. %>
|
|
80
|
+
<% if Livechat.attachments_enabled? %>
|
|
81
|
+
<%= form.file_field :files, multiple: true, class: 'reply-file',
|
|
82
|
+
aria: { label: t('livechat.dashboard.attach', default: 'Attach files') } %>
|
|
83
|
+
<% end %>
|
|
84
|
+
<div class="reply-toolbar">
|
|
85
|
+
<div class="reply-tools"></div>
|
|
86
|
+
<button type="submit" class="send"
|
|
87
|
+
aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
|
|
88
|
+
title="<%= t('livechat.dashboard.send', default: 'Send') %>">
|
|
89
|
+
<%# Paper airplane (Heroicons, MIT) — same icon as the widget. %>
|
|
90
|
+
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"/></svg>
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
@@ -1,88 +1,105 @@
|
|
|
1
|
+
<% content_for :body_class, 'lvc-inbox' %>
|
|
2
|
+
|
|
1
3
|
<h1><%= t('livechat.dashboard.title', default: 'Chat') %></h1>
|
|
2
4
|
|
|
3
|
-
<div class="
|
|
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
|