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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +47 -1
  4. data/app/controllers/livechat/attachments_controller.rb +61 -0
  5. data/app/controllers/livechat/conversations_controller.rb +1 -17
  6. data/app/controllers/livechat/messages_controller.rb +2 -1
  7. data/app/controllers/livechat/visitor_controller.rb +13 -2
  8. data/app/helpers/livechat/inbox_helper.rb +32 -0
  9. data/app/models/livechat/conversation.rb +22 -6
  10. data/app/models/livechat/message.rb +110 -2
  11. data/app/views/layouts/livechat/application.html.erb +9 -1
  12. data/app/views/livechat/conversations/index.html.erb +3 -1
  13. data/app/views/livechat/conversations/show.html.erb +14 -5
  14. data/config/locales/livechat.ar.yml +8 -0
  15. data/config/locales/livechat.bg.yml +8 -0
  16. data/config/locales/livechat.bn.yml +8 -0
  17. data/config/locales/livechat.de.yml +8 -0
  18. data/config/locales/livechat.el.yml +8 -0
  19. data/config/locales/livechat.en.yml +8 -0
  20. data/config/locales/livechat.es.yml +8 -0
  21. data/config/locales/livechat.fr.yml +8 -0
  22. data/config/locales/livechat.hi.yml +8 -0
  23. data/config/locales/livechat.hr.yml +8 -0
  24. data/config/locales/livechat.id.yml +8 -0
  25. data/config/locales/livechat.it.yml +8 -0
  26. data/config/locales/livechat.ja.yml +8 -0
  27. data/config/locales/livechat.ko.yml +8 -0
  28. data/config/locales/livechat.lb.yml +8 -0
  29. data/config/locales/livechat.nl.yml +8 -0
  30. data/config/locales/livechat.pl.yml +8 -0
  31. data/config/locales/livechat.pt.yml +8 -0
  32. data/config/locales/livechat.ro.yml +8 -0
  33. data/config/locales/livechat.ru.yml +8 -0
  34. data/config/locales/livechat.th.yml +8 -0
  35. data/config/locales/livechat.tr.yml +8 -0
  36. data/config/locales/livechat.uk.yml +8 -0
  37. data/config/locales/livechat.ur.yml +8 -0
  38. data/config/locales/livechat.vi.yml +8 -0
  39. data/config/locales/livechat.zh-CN.yml +8 -0
  40. data/config/routes.rb +6 -0
  41. data/lib/generators/livechat/install/templates/initializer.rb +15 -0
  42. data/lib/livechat/channels.rb +27 -0
  43. data/lib/livechat/configuration.rb +28 -0
  44. data/lib/livechat/dashboard.js +73 -5
  45. data/lib/livechat/engine.rb +7 -0
  46. data/lib/livechat/version.rb +1 -1
  47. data/lib/livechat/widget.js +220 -12
  48. data/lib/livechat/widget.rb +6 -1
  49. data/lib/livechat.rb +31 -0
  50. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af07804137de8734d4851efdea56a8715af2ab199cdd99694f896abf42f908c9
4
- data.tar.gz: 4272cb7b782303cd4ff21de16b16f849b9a32c3b4b3e91a057fe70fcc23aa86a
3
+ metadata.gz: e4589546ac2f8ee66f5fab13591998a651425a7afdb8811fd9b24dd50e7baa65
4
+ data.tar.gz: 8e49eba0d5232f569ce96cb681ead311749247ef3eeb476d2ea4e2fe53bda2ad
5
5
  SHA512:
6
- metadata.gz: 3a0064196bc9737e6f008aa3f558d364ad2c29d09701774e629bc4bbb25a23074088597ec8e9a55c228b1dd20d7cbc8e6c5186fc35ac3e2a34445a710a793638
7
- data.tar.gz: b41ce44b50df38aa11228ab5082f755ce4424d0dd3955a5d1e41af53829f1210492d13fa02e716d2b146e08bb54125f05b336396b3944c25953645c2e7a972b7
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| thread_message_json(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.create!(author_type: 'visitor', body: body)
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.create!(author_type: 'agent', body: body,
54
- agent_id: agent_id.to_s, agent_label: agent_label)
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.body.to_s.truncate(140) unless message.system?
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, presence: true, length: { maximum: MAX_BODY_LENGTH }, unless: :system?
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 { conversation.register_message(self) }
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 textarea { flex: 1; resize: vertical; min-height: 44px; max-height: 200px; padding: 8px 10px;
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('-') %>"></div>
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, class: 'reply' do |form| %>
62
- <%= form.text_area :body, rows: 2, autofocus: true, required: true,
63
- placeholder: t('livechat.dashboard.reply_placeholder', default: 'Write your reply…') %>
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: