livechat 0.3.5 → 0.4.0
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 +29 -0
- data/README.md +47 -1
- data/app/controllers/livechat/attachments_controller.rb +61 -0
- data/app/controllers/livechat/conversations_controller.rb +1 -17
- data/app/controllers/livechat/messages_controller.rb +2 -1
- data/app/controllers/livechat/visitor_controller.rb +13 -2
- data/app/helpers/livechat/inbox_helper.rb +32 -0
- data/app/models/livechat/conversation.rb +22 -6
- data/app/models/livechat/message.rb +110 -2
- data/app/views/layouts/livechat/application.html.erb +9 -1
- data/app/views/livechat/conversations/index.html.erb +3 -1
- data/app/views/livechat/conversations/show.html.erb +14 -5
- data/config/locales/livechat.ar.yml +8 -0
- data/config/locales/livechat.bg.yml +8 -0
- data/config/locales/livechat.bn.yml +8 -0
- data/config/locales/livechat.de.yml +8 -0
- data/config/locales/livechat.el.yml +8 -0
- data/config/locales/livechat.en.yml +8 -0
- data/config/locales/livechat.es.yml +8 -0
- data/config/locales/livechat.fr.yml +8 -0
- data/config/locales/livechat.hi.yml +8 -0
- data/config/locales/livechat.hr.yml +8 -0
- data/config/locales/livechat.id.yml +8 -0
- data/config/locales/livechat.it.yml +8 -0
- data/config/locales/livechat.ja.yml +8 -0
- data/config/locales/livechat.ko.yml +8 -0
- data/config/locales/livechat.lb.yml +8 -0
- data/config/locales/livechat.nl.yml +8 -0
- data/config/locales/livechat.pl.yml +8 -0
- data/config/locales/livechat.pt.yml +8 -0
- data/config/locales/livechat.ro.yml +8 -0
- data/config/locales/livechat.ru.yml +8 -0
- data/config/locales/livechat.th.yml +8 -0
- data/config/locales/livechat.tr.yml +8 -0
- data/config/locales/livechat.uk.yml +8 -0
- data/config/locales/livechat.ur.yml +8 -0
- data/config/locales/livechat.vi.yml +8 -0
- data/config/locales/livechat.zh-CN.yml +8 -0
- data/config/routes.rb +6 -0
- data/lib/generators/livechat/install/templates/initializer.rb +15 -0
- data/lib/livechat/channels.rb +27 -0
- data/lib/livechat/configuration.rb +28 -0
- data/lib/livechat/dashboard.js +73 -5
- data/lib/livechat/engine.rb +7 -0
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +220 -12
- data/lib/livechat/widget.rb +6 -1
- data/lib/livechat.rb +31 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4589546ac2f8ee66f5fab13591998a651425a7afdb8811fd9b24dd50e7baa65
|
|
4
|
+
data.tar.gz: 8e49eba0d5232f569ce96cb681ead311749247ef3eeb476d2ea4e2fe53bda2ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5ecb5d39149403a4da90f828ddd0bad398c0ad431300237a11063d18db31f989ab4f899ab834ba9801c88085d02367541e8e88b61db0563039d475eca1febac
|
|
7
|
+
data.tar.gz: 2160a9f6928f2acd664a36267faac734bdb462311d0ad1efe2429f56687de91f22e149c483e1561e65666aec1cb6b8db457f53b6ca92bdf3c89f2d1f14699a0c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- **File attachments.** Visitors and agents can attach images and documents to
|
|
6
|
+
a message — a paperclip in the widget composer, a file field on the inbox
|
|
7
|
+
reply. Images render inline in the thread; other files show as download
|
|
8
|
+
links. Every file is served through the engine's own gated route (agent, or
|
|
9
|
+
the visitor who owns the conversation) and streamed inline — never a public,
|
|
10
|
+
long-lived Active Storage URL. A message can carry files with no text.
|
|
11
|
+
On by default wherever the host has Active Storage; where it's absent the
|
|
12
|
+
widget quietly stays text-only. Tunable: `attach_files`, `max_attachments`,
|
|
13
|
+
`max_attachment_size`, `allowed_attachment_types`.
|
|
14
|
+
- **Optional Action Cable push.** Off by default — polling remains the
|
|
15
|
+
transport and needs nothing from your app. Turn on `config.action_cable` and
|
|
16
|
+
a new message nudges the widget and the inbox to refresh the instant it's
|
|
17
|
+
sent, with polling still the fallback (a dropped socket never means a missed
|
|
18
|
+
message). The widget speaks the Action Cable protocol over a plain
|
|
19
|
+
WebSocket — no `@rails/actioncable`, no build step — and only ever subscribes
|
|
20
|
+
to a per-conversation stream the server cryptographically signed for it, so
|
|
21
|
+
no one can listen to a thread they weren't shown.
|
|
22
|
+
- New attachment strings translated across all 26 shipped locales.
|
|
23
|
+
|
|
24
|
+
## 0.3.6
|
|
25
|
+
|
|
26
|
+
- The opening greeting is now styled as a support message bubble (with a
|
|
27
|
+
generic "Support" label) instead of a muted line, so the panel reads like
|
|
28
|
+
a warmly-opened conversation. Deliberately generic — no fake individual,
|
|
29
|
+
avatar, or presence — and still client-only (never stored, never in the
|
|
30
|
+
agent inbox).
|
|
31
|
+
|
|
3
32
|
## 0.3.5
|
|
4
33
|
|
|
5
34
|
- The inbox thread now appends new messages live instead of showing a
|
data/README.md
CHANGED
|
@@ -28,6 +28,10 @@ there. Every word stays in your database.
|
|
|
28
28
|
- **Real attribution.** Signed-in visitors are identified server-side against
|
|
29
29
|
your user records and keep their thread across devices. Guests get a
|
|
30
30
|
cookie, and keep their history when they sign up.
|
|
31
|
+
- **Send files, both ways.** Visitors and agents attach images and documents;
|
|
32
|
+
images show inline. Files are served through the engine — gated exactly like
|
|
33
|
+
the chat — never a public blob URL. Needs Active Storage; degrades to
|
|
34
|
+
text-only where it's absent.
|
|
31
35
|
- **26 languages.** The widget follows your app's locale, RTL included.
|
|
32
36
|
|
|
33
37
|
## How it works
|
|
@@ -45,7 +49,9 @@ there. Every word stays in your database.
|
|
|
45
49
|
Realtime is polling, on purpose: ~4s while the panel is open, ~30s in the
|
|
46
50
|
background, nothing at all for visitors who never wrote. No Action Cable,
|
|
47
51
|
no Redis, no infrastructure. At support-chat volume you will not notice;
|
|
48
|
-
your ops person will notice there is nothing new to run.
|
|
52
|
+
your ops person will notice there is nothing new to run. If you already run
|
|
53
|
+
Action Cable and want replies to land the instant they're sent, turn it on
|
|
54
|
+
(`config.action_cable = true`) — polling stays the fallback.
|
|
49
55
|
|
|
50
56
|
## Requirements
|
|
51
57
|
|
|
@@ -139,6 +145,46 @@ For anything else, hook in:
|
|
|
139
145
|
config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
|
|
140
146
|
```
|
|
141
147
|
|
|
148
|
+
### File attachments
|
|
149
|
+
|
|
150
|
+
On by default wherever the app has Active Storage. If you don't already:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bin/rails active_storage:install && bin/rails db:migrate
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Visitors get a paperclip in the composer; agents get a file field on the
|
|
157
|
+
reply form. Images render inline in the thread, other files as download
|
|
158
|
+
links. Every file is served through the engine's own route and gated the
|
|
159
|
+
same way the chat is — an agent, or the visitor who owns that conversation —
|
|
160
|
+
so nothing leaks through a guessable or long-lived blob URL.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
config.attach_files = true # false turns it off even with Active Storage
|
|
164
|
+
config.max_attachments = 5 # per message
|
|
165
|
+
config.max_attachment_size = 10.megabytes
|
|
166
|
+
config.allowed_attachment_types = nil # or an allowlist, e.g. %w[image/png image/jpeg application/pdf]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Where Active Storage isn't installed, the widget quietly stays text-only.
|
|
170
|
+
|
|
171
|
+
### Realtime with Action Cable (optional)
|
|
172
|
+
|
|
173
|
+
Polling is the default and needs nothing from your app. If you already run
|
|
174
|
+
Action Cable, turn on push so a reply appears the instant it's sent:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
config.action_cable = true
|
|
178
|
+
config.action_cable_url = "/cable" # match your `mount ActionCable... => ...`
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A new message nudges the widget and the inbox to refresh at once; polling
|
|
182
|
+
stays the fallback, so a dropped socket or a proxy that blocks WebSockets
|
|
183
|
+
never means a missed message. The widget speaks the Action Cable protocol
|
|
184
|
+
over a plain WebSocket — no `@rails/actioncable`, no build step — and only
|
|
185
|
+
ever subscribes to a stream the server signed for it. Under a strict CSP,
|
|
186
|
+
allow the socket with `connect-src 'self'`.
|
|
187
|
+
|
|
142
188
|
## The inbox
|
|
143
189
|
|
|
144
190
|
Browse at the mount path: open and resolved tabs, unread badges, search
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Serves message attachments through the engine — never a public Active
|
|
5
|
+
# Storage URL — so every download is gated the same way the rest of the
|
|
6
|
+
# chat is: you get the file only if you can work the inbox, or you are the
|
|
7
|
+
# visitor whose conversation it belongs to. Streamed inline (not redirected
|
|
8
|
+
# to a signed blob URL), so no long-lived link ever escapes the gate.
|
|
9
|
+
class AttachmentsController < ApplicationController
|
|
10
|
+
def show
|
|
11
|
+
attachment = find_attachment
|
|
12
|
+
return head :not_found unless attachment
|
|
13
|
+
|
|
14
|
+
message = attachment.record
|
|
15
|
+
return head :forbidden unless message.is_a?(Livechat::Message) &&
|
|
16
|
+
authorized_for?(message.conversation)
|
|
17
|
+
|
|
18
|
+
stream(attachment.blob)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_attachment
|
|
24
|
+
return unless Livechat.attachments_enabled?
|
|
25
|
+
|
|
26
|
+
ActiveStorage::Attachment.find_by(id: params[:id], name: 'files',
|
|
27
|
+
record_type: Livechat::Message.name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def authorized_for?(conversation)
|
|
31
|
+
return true if Livechat.agent?(request)
|
|
32
|
+
return false unless Livechat.enabled?(request)
|
|
33
|
+
|
|
34
|
+
owns_conversation?(conversation)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mirrors Conversation.for_visitor: a signed-in visitor owns their thread
|
|
38
|
+
# by id; a guest owns an unclaimed thread carrying their cookie token.
|
|
39
|
+
def owns_conversation?(conversation)
|
|
40
|
+
if current_visitor_id
|
|
41
|
+
conversation.visitor_id == current_visitor_id
|
|
42
|
+
elsif visitor_token.present?
|
|
43
|
+
conversation.visitor_id.nil? && conversation.visitor_token == visitor_token
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Images render inline (so the widget can show them in an <img>); anything
|
|
50
|
+
# else downloads. nosniff keeps the browser from second-guessing the type
|
|
51
|
+
# of a visitor-supplied file.
|
|
52
|
+
def stream(blob)
|
|
53
|
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
54
|
+
inline = blob.content_type.to_s.start_with?('image/')
|
|
55
|
+
send_data blob.download,
|
|
56
|
+
filename: blob.filename.to_s,
|
|
57
|
+
type: blob.content_type.presence || 'application/octet-stream',
|
|
58
|
+
disposition: inline ? 'inline' : 'attachment'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -58,7 +58,7 @@ module Livechat
|
|
|
58
58
|
|
|
59
59
|
render json: {
|
|
60
60
|
status: @conversation.status,
|
|
61
|
-
messages: messages.map { |message|
|
|
61
|
+
messages: messages.map { |message| message.as_inbox_json(@conversation) }
|
|
62
62
|
}
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -78,22 +78,6 @@ module Livechat
|
|
|
78
78
|
@conversation = Conversation.find(params[:id])
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# The fields dashboard.js needs to render a message bubble, matching the
|
|
82
|
-
# thread partial: system events carry a ready localized line; visitor and
|
|
83
|
-
# agent messages carry a display name (for the author header) and body.
|
|
84
|
-
def thread_message_json(message)
|
|
85
|
-
if message.system?
|
|
86
|
-
{ id: message.id, author: 'system',
|
|
87
|
-
text: t("livechat.events.#{message.event}", agent: message.agent_label,
|
|
88
|
-
default: "%{agent} #{message.event} the conversation"),
|
|
89
|
-
at: message.created_at.to_fs(:short) }
|
|
90
|
-
else
|
|
91
|
-
{ id: message.id, author: message.author_type,
|
|
92
|
-
name: message.agent? ? message.agent_label : @conversation.display_name,
|
|
93
|
-
body: message.body, at: message.created_at.to_fs(:short) }
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
81
|
# Case-insensitive match on who the visitor is or anything anyone wrote.
|
|
98
82
|
# LOWER(...) LIKE is deliberately plain SQL — portable across SQLite,
|
|
99
83
|
# PostgreSQL and MySQL alike (feedback_engine's proven approach).
|
|
@@ -9,7 +9,8 @@ module Livechat
|
|
|
9
9
|
message = conversation.post_agent_message!(
|
|
10
10
|
body: params[:body].to_s.strip,
|
|
11
11
|
agent_id: current_agent_id,
|
|
12
|
-
agent_label: current_agent_label
|
|
12
|
+
agent_label: current_agent_label,
|
|
13
|
+
files: params[:files]
|
|
13
14
|
)
|
|
14
15
|
Notifications.agent_message(message)
|
|
15
16
|
|
|
@@ -28,6 +28,7 @@ module Livechat
|
|
|
28
28
|
status: conversation.status,
|
|
29
29
|
unread: conversation.messages.from_agent.unread.count,
|
|
30
30
|
email: conversation.visitor_email.present?,
|
|
31
|
+
cable: cable_payload(conversation),
|
|
31
32
|
messages: messages.map(&:as_widget_json)
|
|
32
33
|
}
|
|
33
34
|
end
|
|
@@ -40,7 +41,7 @@ module Livechat
|
|
|
40
41
|
# conversation behind.
|
|
41
42
|
ActiveRecord::Base.transaction do
|
|
42
43
|
conversation = find_conversation || start_conversation
|
|
43
|
-
message = conversation.post_visitor_message!(params[:body].to_s.strip)
|
|
44
|
+
message = conversation.post_visitor_message!(params[:body].to_s.strip, files: params[:files])
|
|
44
45
|
end
|
|
45
46
|
refresh_context(message.conversation)
|
|
46
47
|
Notifications.visitor_message(message)
|
|
@@ -102,7 +103,17 @@ module Livechat
|
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
def empty_state
|
|
105
|
-
{ status: nil, unread: 0, email: false, messages: [] }
|
|
106
|
+
{ status: nil, unread: 0, email: false, cable: nil, messages: [] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The signed stream token and cable URL the widget subscribes with, or nil
|
|
110
|
+
# when push is off. The token is scoped to this conversation and only
|
|
111
|
+
# reaches a visitor who already passed the gate to read it.
|
|
112
|
+
def cable_payload(conversation)
|
|
113
|
+
return unless Livechat.action_cable_enabled?
|
|
114
|
+
|
|
115
|
+
{ url: Livechat.config.action_cable_url,
|
|
116
|
+
stream: Livechat.sign_stream("livechat:conversation:#{conversation.id}") }
|
|
106
117
|
end
|
|
107
118
|
end
|
|
108
119
|
end
|
|
@@ -14,5 +14,37 @@ module Livechat
|
|
|
14
14
|
|
|
15
15
|
tag.span(initials, class: "avatar color-#{color}", title: label)
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
# A message's attachments, rendered like the widget: images inline as
|
|
19
|
+
# thumbnails, everything else as a download link. Both point at the gated
|
|
20
|
+
# engine route, never a public blob URL.
|
|
21
|
+
def livechat_message_attachments(message)
|
|
22
|
+
attachments = message.attachments_json
|
|
23
|
+
return if attachments.empty?
|
|
24
|
+
|
|
25
|
+
tag.div(class: 'atts') do
|
|
26
|
+
safe_join(attachments.map { |attachment| livechat_attachment_link(attachment) })
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# data-* attributes that hand dashboard.js a signed cable stream to watch,
|
|
31
|
+
# or {} when push is off. Merge into the element that already polls, so a
|
|
32
|
+
# nudge just fires its existing refresh sooner.
|
|
33
|
+
def livechat_cable_data(stream_name)
|
|
34
|
+
return {} unless Livechat.action_cable_enabled?
|
|
35
|
+
|
|
36
|
+
{ 'cable-url' => Livechat.config.action_cable_url,
|
|
37
|
+
'cable-stream' => Livechat.sign_stream(stream_name) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def livechat_attachment_link(attachment)
|
|
41
|
+
if attachment[:image]
|
|
42
|
+
link_to image_tag(attachment[:url], alt: attachment[:name], loading: 'lazy'),
|
|
43
|
+
attachment[:url], target: '_blank', rel: 'noopener', class: 'att-img'
|
|
44
|
+
else
|
|
45
|
+
link_to attachment[:name], attachment[:url],
|
|
46
|
+
target: '_blank', rel: 'noopener', class: 'att-file'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
17
49
|
end
|
|
18
50
|
end
|
|
@@ -42,16 +42,22 @@ module Livechat
|
|
|
42
42
|
.update_all(visitor_id: visitor_id.to_s, visitor_label: visitor_label)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
def post_visitor_message!(body)
|
|
45
|
+
def post_visitor_message!(body, files: nil)
|
|
46
46
|
transaction do
|
|
47
47
|
update!(status: 'open') if resolved?
|
|
48
|
-
messages.
|
|
48
|
+
message = messages.new(author_type: 'visitor', body: body)
|
|
49
|
+
attach(message, files)
|
|
50
|
+
message.save!
|
|
51
|
+
message
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def post_agent_message!(body:, agent_id:, agent_label:)
|
|
53
|
-
messages.
|
|
54
|
-
|
|
55
|
+
def post_agent_message!(body:, agent_id:, agent_label:, files: nil)
|
|
56
|
+
message = messages.new(author_type: 'agent', body: body,
|
|
57
|
+
agent_id: agent_id.to_s, agent_label: agent_label)
|
|
58
|
+
attach(message, files)
|
|
59
|
+
message.save!
|
|
60
|
+
message
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def resolve_by!(agent_label)
|
|
@@ -76,7 +82,7 @@ module Livechat
|
|
|
76
82
|
# with update_columns: cheap, no callbacks, no updated_at churn.
|
|
77
83
|
def register_message(message)
|
|
78
84
|
updates = { last_activity_at: message.created_at }
|
|
79
|
-
updates[:last_message_preview] = message.
|
|
85
|
+
updates[:last_message_preview] = message.preview unless message.system?
|
|
80
86
|
update_columns(updates)
|
|
81
87
|
end
|
|
82
88
|
|
|
@@ -96,6 +102,16 @@ module Livechat
|
|
|
96
102
|
|
|
97
103
|
private
|
|
98
104
|
|
|
105
|
+
# Attach only real uploads, and only where attachments are enabled — a
|
|
106
|
+
# host without Active Storage (or with attachments off) drops the files
|
|
107
|
+
# silently and the text message still goes through.
|
|
108
|
+
def attach(message, files)
|
|
109
|
+
return unless Livechat.attachments_enabled?
|
|
110
|
+
|
|
111
|
+
uploads = Array(files).select { |file| file.respond_to?(:read) }
|
|
112
|
+
message.files.attach(uploads) if uploads.any?
|
|
113
|
+
end
|
|
114
|
+
|
|
99
115
|
# Every conversation belongs to someone — a signed-in user or at least a
|
|
100
116
|
# guest cookie. An unattributable thread could never be shown to its
|
|
101
117
|
# visitor again.
|
|
@@ -10,13 +10,22 @@ module Livechat
|
|
|
10
10
|
EVENTS = %w[resolved reopened].freeze
|
|
11
11
|
MAX_BODY_LENGTH = 5_000
|
|
12
12
|
|
|
13
|
+
# Attachments ride on Active Storage, which the host installs (it's a
|
|
14
|
+
# standard Rails feature, not a livechat table). Where it isn't present
|
|
15
|
+
# the association simply isn't declared and everything stays text-only.
|
|
16
|
+
ATTACHMENTS_SUPPORTED = defined?(ActiveStorage) ? true : false
|
|
17
|
+
def self.attachments_supported? = ATTACHMENTS_SUPPORTED
|
|
18
|
+
|
|
13
19
|
belongs_to :conversation
|
|
20
|
+
has_many_attached :files if ATTACHMENTS_SUPPORTED
|
|
14
21
|
|
|
15
22
|
validates :author_type, inclusion: { in: AUTHOR_TYPES }
|
|
16
|
-
validates :body,
|
|
23
|
+
validates :body, length: { maximum: MAX_BODY_LENGTH }, allow_blank: true
|
|
24
|
+
validates :body, presence: true, unless: :body_optional?
|
|
17
25
|
validates :agent_id, presence: true, if: :agent?
|
|
18
26
|
validates :event, presence: true, inclusion: { in: EVENTS }, if: :system?
|
|
19
27
|
validates :event, absence: true, unless: :system?
|
|
28
|
+
validate :attachments_within_limits, if: :files_attached?
|
|
20
29
|
|
|
21
30
|
scope :chronological, -> { order(:id) }
|
|
22
31
|
scope :after_id, ->(id) { where(id: (id.to_i + 1)..) }
|
|
@@ -24,7 +33,7 @@ module Livechat
|
|
|
24
33
|
scope :from_agent, -> { where(author_type: 'agent') }
|
|
25
34
|
scope :unread, -> { where(read_at: nil) }
|
|
26
35
|
|
|
27
|
-
after_create_commit
|
|
36
|
+
after_create_commit :on_created
|
|
28
37
|
|
|
29
38
|
AUTHOR_TYPES.each do |type|
|
|
30
39
|
define_method(:"#{type}?") { author_type == type }
|
|
@@ -39,6 +48,26 @@ module Livechat
|
|
|
39
48
|
agent? ? Livechat.display_name_for(agent_label) : nil
|
|
40
49
|
end
|
|
41
50
|
|
|
51
|
+
# The attached files, or an empty list where Active Storage is absent or
|
|
52
|
+
# attachments are off — callers never have to branch on support.
|
|
53
|
+
def attached_files
|
|
54
|
+
files_attached? ? files : []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def files_attached?
|
|
58
|
+
Livechat.attachments_enabled? && files.attached?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A one-line summary for the inbox list: the body, or a note of what was
|
|
62
|
+
# attached when a message is files-only.
|
|
63
|
+
def preview
|
|
64
|
+
return body.to_s.truncate(140) if body.present?
|
|
65
|
+
return '' unless files_attached?
|
|
66
|
+
|
|
67
|
+
names = files.map { |file| file.filename.to_s }
|
|
68
|
+
names.one? ? "📎 #{names.first}" : "📎 #{names.size} files"
|
|
69
|
+
end
|
|
70
|
+
|
|
42
71
|
def as_widget_json
|
|
43
72
|
{
|
|
44
73
|
id: id,
|
|
@@ -46,8 +75,87 @@ module Livechat
|
|
|
46
75
|
label: public_label,
|
|
47
76
|
body: system? ? nil : body,
|
|
48
77
|
event: event,
|
|
78
|
+
attachments: attachments_json,
|
|
49
79
|
at: created_at.iso8601
|
|
50
80
|
}
|
|
51
81
|
end
|
|
82
|
+
|
|
83
|
+
# The shape the inbox renders — mirrors the thread partial. system events
|
|
84
|
+
# carry a ready localized line; visitor and agent messages carry a display
|
|
85
|
+
# name for the author header. Lives here (not in the controller) so an
|
|
86
|
+
# Action Cable broadcast can build it without a request.
|
|
87
|
+
def as_inbox_json(conversation = self.conversation)
|
|
88
|
+
if system?
|
|
89
|
+
{ id: id, author: 'system',
|
|
90
|
+
text: I18n.t("livechat.events.#{event}", agent: agent_label,
|
|
91
|
+
default: "%{agent} #{event} the conversation"),
|
|
92
|
+
at: created_at.to_fs(:short) }
|
|
93
|
+
else
|
|
94
|
+
{ id: id, author: author_type,
|
|
95
|
+
name: agent? ? agent_label : conversation.display_name,
|
|
96
|
+
body: body, attachments: attachments_json, at: created_at.to_fs(:short) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# [{ id:, name:, url:, image:, size: }] — url is the gated engine route,
|
|
101
|
+
# image flags the ones the client renders inline instead of as a link.
|
|
102
|
+
def attachments_json
|
|
103
|
+
return [] unless files_attached?
|
|
104
|
+
|
|
105
|
+
base = Livechat.config.mount_path.to_s.chomp('/')
|
|
106
|
+
files.map do |file|
|
|
107
|
+
{ id: file.id,
|
|
108
|
+
name: file.filename.to_s,
|
|
109
|
+
url: "#{base}/attachments/#{file.id}",
|
|
110
|
+
image: file.content_type.to_s.start_with?('image/'),
|
|
111
|
+
size: file.byte_size }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# A visitor/agent message needs a body unless it carries a file instead —
|
|
118
|
+
# an image with no caption is a perfectly good message. System messages
|
|
119
|
+
# never have a body.
|
|
120
|
+
def body_optional?
|
|
121
|
+
system? || files_attached?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def attachments_within_limits
|
|
125
|
+
config = Livechat.config
|
|
126
|
+
if files.size > config.max_attachments
|
|
127
|
+
errors.add(:base, I18n.t('livechat.attachments.too_many', count: config.max_attachments,
|
|
128
|
+
default: 'Too many files (max %{count}).'))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
files.each do |file|
|
|
132
|
+
if file.byte_size > config.max_attachment_size
|
|
133
|
+
errors.add(:base, I18n.t('livechat.attachments.too_large', name: file.filename,
|
|
134
|
+
default: '%{name} is too large.'))
|
|
135
|
+
end
|
|
136
|
+
types = config.allowed_attachment_types
|
|
137
|
+
next if types.blank? || types.include?(file.content_type)
|
|
138
|
+
|
|
139
|
+
errors.add(:base, I18n.t('livechat.attachments.type_rejected', name: file.filename,
|
|
140
|
+
default: "%{name}'s type isn't allowed."))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def on_created
|
|
145
|
+
conversation.register_message(self)
|
|
146
|
+
broadcast_new_message
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Nudge — not the payload — so the client refetches through its normal,
|
|
150
|
+
# authorized path and the rendering/dedup/read logic stays in one place.
|
|
151
|
+
# Rescued: a chat must never break because a cable backend hiccuped.
|
|
152
|
+
def broadcast_new_message
|
|
153
|
+
return unless Livechat.action_cable_enabled?
|
|
154
|
+
|
|
155
|
+
ActionCable.server.broadcast("livechat:conversation:#{conversation_id}", { nudge: id })
|
|
156
|
+
ActionCable.server.broadcast('livechat:inbox', { nudge: id })
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
Rails.logger.error("[livechat] cable broadcast failed: #{e.class}: #{e.message}")
|
|
159
|
+
end
|
|
52
160
|
end
|
|
53
161
|
end
|
|
@@ -85,12 +85,20 @@
|
|
|
85
85
|
.thread .msg.agent { align-self: flex-end; background: var(--accent); color: var(--accent-text);
|
|
86
86
|
border-bottom-right-radius: 4px; }
|
|
87
87
|
.thread .system { align-self: center; color: var(--muted); font-size: 12px; margin: 8px 0; }
|
|
88
|
+
.thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
|
89
|
+
.thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
|
|
90
|
+
.thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px;
|
|
91
|
+
background: rgba(0,0,0,.06); color: inherit; font-size: 13px;
|
|
92
|
+
overflow-wrap: anywhere; }
|
|
93
|
+
.thread .msg.agent .att-file { background: rgba(255,255,255,.2); }
|
|
88
94
|
#new-messages { margin: 12px 0 0; }
|
|
89
95
|
#new-messages a { font-weight: 600; }
|
|
90
96
|
.reply { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border); }
|
|
91
|
-
.reply
|
|
97
|
+
.reply .compose { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
|
98
|
+
.reply textarea { resize: vertical; min-height: 44px; max-height: 200px; padding: 8px 10px;
|
|
92
99
|
border: 1px solid var(--border); border-radius: 10px; font: inherit;
|
|
93
100
|
background: var(--bg); color: var(--text); }
|
|
101
|
+
.reply input[type=file] { font-size: 12px; color: var(--muted); }
|
|
94
102
|
.reply button.send { align-self: flex-end; width: 42px; min-width: 42px; height: 42px; padding: 0;
|
|
95
103
|
display: flex; align-items: center; justify-content: center;
|
|
96
104
|
background: var(--accent); border-color: var(--accent); color: var(--accent-text);
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
<%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
|
|
21
21
|
unless the agent is mid-search, whose typing must never be eaten. %>
|
|
22
|
+
<% cable = livechat_cable_data('livechat:inbox') %>
|
|
22
23
|
<div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
|
|
23
24
|
data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
|
|
24
|
-
@counts.fetch('open', 0)].join('-') %>"
|
|
25
|
+
@counts.fetch('open', 0)].join('-') %>"
|
|
26
|
+
<%= tag.attributes(data: cable) if cable.any? %>></div>
|
|
25
27
|
|
|
26
28
|
<div class="card">
|
|
27
29
|
<% if @conversations.empty? %>
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
|
|
32
32
|
<div class="card">
|
|
33
33
|
<% last = @messages.last %>
|
|
34
|
+
<% cable = livechat_cable_data("livechat:conversation:#{@conversation.id}") %>
|
|
34
35
|
<div class="thread" id="thread"
|
|
35
36
|
data-poll-url="<%= poll_conversation_path(@conversation) %>"
|
|
36
37
|
data-latest="<%= last&.id.to_i %>"
|
|
37
|
-
data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
|
|
38
|
+
data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
|
|
39
|
+
<%= tag.attributes(data: cable) if cable.any? %>>
|
|
38
40
|
<% previous_author = nil %>
|
|
39
41
|
<% @messages.each do |message| %>
|
|
40
42
|
<% if message.system? %>
|
|
@@ -53,14 +55,21 @@
|
|
|
53
55
|
</p>
|
|
54
56
|
<% previous_author = author_key %>
|
|
55
57
|
<% end %>
|
|
56
|
-
<div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %></div>
|
|
58
|
+
<div class="msg <%= message.author_type %>" id="message-<%= message.id %>"><%= message.body %><%= livechat_message_attachments(message) %></div>
|
|
57
59
|
<% end %>
|
|
58
60
|
<% end %>
|
|
59
61
|
</div>
|
|
60
62
|
|
|
61
|
-
<%= form_with url: conversation_messages_path(@conversation), method: :post,
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
<%= form_with url: conversation_messages_path(@conversation), method: :post,
|
|
64
|
+
multipart: true, class: 'reply' do |form| %>
|
|
65
|
+
<div class="compose">
|
|
66
|
+
<%= form.text_area :body, rows: 2, autofocus: true, required: !Livechat.attachments_enabled?,
|
|
67
|
+
placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
|
|
68
|
+
<% if Livechat.attachments_enabled? %>
|
|
69
|
+
<%= form.file_field :files, multiple: true,
|
|
70
|
+
aria: { label: t('livechat.dashboard.attach', default: 'Attach files') } %>
|
|
71
|
+
<% end %>
|
|
72
|
+
</div>
|
|
64
73
|
<button type="submit" class="primary send"
|
|
65
74
|
aria-label="<%= t('livechat.dashboard.send', default: 'Send') %>"
|
|
66
75
|
title="<%= t('livechat.dashboard.send', default: 'Send') %>">
|
|
@@ -17,6 +17,13 @@ ar:
|
|
|
17
17
|
error_send: "تعذر الإرسال. يرجى المحاولة مرة أخرى."
|
|
18
18
|
error_rate_limited: "رسائل كثيرة جدًا. يرجى الانتظار قليلًا والمحاولة مرة أخرى."
|
|
19
19
|
unread_aria: "رسائل غير مقروءة"
|
|
20
|
+
attach: "إرفاق ملفات"
|
|
21
|
+
remove_file: "إزالة الملف"
|
|
22
|
+
attachment_error: "تعذر إرسال بعض الملفات."
|
|
23
|
+
attachments:
|
|
24
|
+
too_many: "ملفات كثيرة جدًا (بحد أقصى %{count})."
|
|
25
|
+
too_large: "%{name} كبير جدًا."
|
|
26
|
+
type_rejected: "نوع %{name} غير مسموح به."
|
|
20
27
|
statuses:
|
|
21
28
|
open: "مفتوحة"
|
|
22
29
|
resolved: "محلولة"
|
|
@@ -36,6 +43,7 @@ ar:
|
|
|
36
43
|
reopen: "إعادة فتح"
|
|
37
44
|
reply_placeholder: "اكتب ردك…"
|
|
38
45
|
send: "إرسال"
|
|
46
|
+
attach: "إرفاق ملفات"
|
|
39
47
|
newer: "الأحدث"
|
|
40
48
|
older: "الأقدم"
|
|
41
49
|
mail:
|
|
@@ -17,6 +17,13 @@ bg:
|
|
|
17
17
|
error_send: "Изпращането не бе успешно. Моля, опитайте отново."
|
|
18
18
|
error_rate_limited: "Твърде много съобщения. Моля, изчакайте малко и опитайте отново."
|
|
19
19
|
unread_aria: "непрочетени съобщения"
|
|
20
|
+
attach: "Прикачване на файлове"
|
|
21
|
+
remove_file: "Премахване на файла"
|
|
22
|
+
attachment_error: "Някои файлове не можаха да бъдат изпратени."
|
|
23
|
+
attachments:
|
|
24
|
+
too_many: "Твърде много файлове (макс. %{count})."
|
|
25
|
+
too_large: "%{name} е твърде голям."
|
|
26
|
+
type_rejected: "Типът на %{name} не е разрешен."
|
|
20
27
|
statuses:
|
|
21
28
|
open: "Отворен"
|
|
22
29
|
resolved: "Разрешен"
|
|
@@ -36,6 +43,7 @@ bg:
|
|
|
36
43
|
reopen: "Отвори отново"
|
|
37
44
|
reply_placeholder: "Напишете отговора си…"
|
|
38
45
|
send: "Изпрати"
|
|
46
|
+
attach: "Прикачване на файлове"
|
|
39
47
|
newer: "По-нови"
|
|
40
48
|
older: "По-стари"
|
|
41
49
|
mail:
|
|
@@ -17,6 +17,13 @@ bn:
|
|
|
17
17
|
error_send: "পাঠানো যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।"
|
|
18
18
|
error_rate_limited: "অনেক বেশি বার্তা। একটু অপেক্ষা করে আবার চেষ্টা করুন।"
|
|
19
19
|
unread_aria: "অপঠিত বার্তা"
|
|
20
|
+
attach: "ফাইল সংযুক্ত করুন"
|
|
21
|
+
remove_file: "ফাইল সরান"
|
|
22
|
+
attachment_error: "কিছু ফাইল পাঠানো যায়নি।"
|
|
23
|
+
attachments:
|
|
24
|
+
too_many: "অনেক বেশি ফাইল (সর্বোচ্চ %{count})।"
|
|
25
|
+
too_large: "%{name} খুব বড়।"
|
|
26
|
+
type_rejected: "%{name}-এর ধরন অনুমোদিত নয়।"
|
|
20
27
|
statuses:
|
|
21
28
|
open: "খোলা"
|
|
22
29
|
resolved: "সমাধান হয়েছে"
|
|
@@ -36,6 +43,7 @@ bn:
|
|
|
36
43
|
reopen: "আবার খুলুন"
|
|
37
44
|
reply_placeholder: "আপনার উত্তর লিখুন…"
|
|
38
45
|
send: "পাঠান"
|
|
46
|
+
attach: "ফাইল সংযুক্ত করুন"
|
|
39
47
|
newer: "নতুনতর"
|
|
40
48
|
older: "পুরনো"
|
|
41
49
|
mail:
|
|
@@ -17,6 +17,13 @@ de:
|
|
|
17
17
|
error_send: "Senden fehlgeschlagen. Bitte versuchen Sie es erneut."
|
|
18
18
|
error_rate_limited: "Zu viele Nachrichten. Bitte warten Sie einen Moment und versuchen Sie es erneut."
|
|
19
19
|
unread_aria: "ungelesene Nachrichten"
|
|
20
|
+
attach: "Dateien anhängen"
|
|
21
|
+
remove_file: "Datei entfernen"
|
|
22
|
+
attachment_error: "Einige Dateien konnten nicht gesendet werden."
|
|
23
|
+
attachments:
|
|
24
|
+
too_many: "Zu viele Dateien (max. %{count})."
|
|
25
|
+
too_large: "%{name} ist zu groß."
|
|
26
|
+
type_rejected: "Der Typ von %{name} ist nicht zulässig."
|
|
20
27
|
statuses:
|
|
21
28
|
open: "Offen"
|
|
22
29
|
resolved: "Gelöst"
|
|
@@ -36,6 +43,7 @@ de:
|
|
|
36
43
|
reopen: "Wieder öffnen"
|
|
37
44
|
reply_placeholder: "Antwort schreiben…"
|
|
38
45
|
send: "Senden"
|
|
46
|
+
attach: "Dateien anhängen"
|
|
39
47
|
newer: "Neuere"
|
|
40
48
|
older: "Ältere"
|
|
41
49
|
mail:
|