livechat 0.7.2 → 0.8.1
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 +32 -0
- data/README.md +1 -0
- data/app/controllers/concerns/livechat/request_context.rb +85 -0
- data/app/controllers/livechat/application_controller.rb +10 -73
- data/app/controllers/livechat/conversations_controller.rb +1 -3
- data/app/controllers/livechat/dashboard_controller.rb +30 -0
- data/app/controllers/livechat/messages_controller.rb +1 -3
- data/app/views/layouts/livechat/application.html.erb +11 -12
- data/app/views/livechat/conversations/index.html.erb +91 -89
- data/app/views/livechat/conversations/show.html.erb +6 -4
- data/app/views/livechat/shared/_dashboard.html.erb +18 -0
- data/config/routes.rb +9 -4
- data/lib/generators/livechat/install/install_generator.rb +2 -6
- data/lib/generators/livechat/install/templates/create_livechat_tables.rb.tt +5 -3
- data/lib/generators/livechat/install/templates/initializer.rb +8 -0
- data/lib/generators/livechat/migration_helpers.rb +43 -0
- data/lib/livechat/configuration.rb +21 -1
- data/lib/livechat/dashboard.css +216 -231
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat.rb +7 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6007e2a9721aeb42f80cee9f9e5609adff1ae7edd787f9329efd74d1d92f94b5
|
|
4
|
+
data.tar.gz: 0d9769f92ead60fbb908447204bc7a9dabbf33f2ef8b1eb4b4f501e0df29e7b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebfe7ab9b60281cb8199960e5cd3a60e5a9af4ee6b0222b8039e3e0103d25e69ea567548f3dcd9252cd881aec235bb4d0187a72f714f402cb84b972b441bd5c4
|
|
7
|
+
data.tar.gz: f1018b0d5775439bd90f87520a4a22ba44c1703e8b2a163940403f656ebcb793945e8a99a640267f31bf1215c517d4cf7925d7c6ed9be1a05ca8baa5ca8280c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **`config.agent_layout` now works on its own.** The inbox's stylesheet and
|
|
6
|
+
script were declared in the gem's layout, so replacing that layout dropped
|
|
7
|
+
both: unstyled inbox with its thread polling and keyboard submit dead. They
|
|
8
|
+
move into the views, so every layout gets them with nothing asked of the host.
|
|
9
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
10
|
+
styled bare `*`, `body` and `a`, and its `.container`, `.card` and `.tabs` are
|
|
11
|
+
names other frameworks use too. Component rules now nest inside a
|
|
12
|
+
`.lvc-dashboard` wrapper the views render, and every custom property is
|
|
13
|
+
`--lvc-` prefixed — that collision ran both ways, so a host defining `--bg`
|
|
14
|
+
recoloured the inbox just as easily.
|
|
15
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
16
|
+
inherits from and the inbox adopts its layout, helpers, authentication and
|
|
17
|
+
request context. It reparents the inbox only — the widget, visitor API and
|
|
18
|
+
attachment proxy stay on the engine's public controller, so it can never
|
|
19
|
+
demand a staff session from a visitor starting a chat. Default is unchanged.
|
|
20
|
+
- **Migrations follow the host's `primary_key_type`,** including the engine's own
|
|
21
|
+
`messages -> conversations` foreign key, which has to match or a bigint column
|
|
22
|
+
ends up pointing at a uuid table. A uuid-keyed app has a uuid
|
|
23
|
+
`active_storage_attachments.record_id`, so bigint tables here could never hold
|
|
24
|
+
a message attachment: `attach` raised `NotNullViolation`.
|
|
25
|
+
- **Dropped the `id: /\d+/` constraints** on the attachment and conversation
|
|
26
|
+
routes, which were what forced the tables to be bigint. Ordering already
|
|
27
|
+
disambiguates: every fixed-name route is declared first.
|
|
28
|
+
- **`t.references :conversation` no longer creates its own index.**
|
|
29
|
+
`(conversation_id)` is a leftmost prefix of the `(conversation_id, id)` index
|
|
30
|
+
the migration already adds, so it answered no query the wider one could not.
|
|
31
|
+
Existing installs keep theirs until they drop it:
|
|
32
|
+
`remove_index :livechat_messages, :conversation_id`.
|
|
33
|
+
- A `BackboneTest` now fails the build on any of the above regressing.
|
|
34
|
+
|
|
3
35
|
## 0.7.2
|
|
4
36
|
|
|
5
37
|
- Adds `AGENTS.md`: install and integration instructions written for coding
|
data/README.md
CHANGED
|
@@ -126,6 +126,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
126
126
|
| Option | Default | What it does |
|
|
127
127
|
| --- | --- | --- |
|
|
128
128
|
| `authorize_agent` | development only | **Who can read the inbox.** Override before deploying |
|
|
129
|
+
| `base_controller_class` | `ActionController::Base` | Controller the inbox inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
129
130
|
| `enabled` | everyone | Who sees the widget. `false` hides it and rejects posts |
|
|
130
131
|
| `current_user` | `nil` | Identify the visitor. Receives the request |
|
|
131
132
|
| `app_name` | Rails app name | Shown in the widget header |
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Who is asking — visitor or agent — and the gates that answer it.
|
|
5
|
+
#
|
|
6
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
7
|
+
# controller roots: the widget's endpoints hang off ActionController::Base, and
|
|
8
|
+
# the inbox hangs off whatever the host set as `base_controller_class`.
|
|
9
|
+
module RequestContext
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def livechat_agent_layout
|
|
15
|
+
Livechat.config.agent_layout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_visitor
|
|
19
|
+
return @current_visitor if defined?(@current_visitor)
|
|
20
|
+
|
|
21
|
+
@current_visitor = Livechat.config.current_user.call(request)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current_visitor_id
|
|
25
|
+
current_visitor.respond_to?(:id) ? current_visitor.id.to_s : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def require_enabled
|
|
29
|
+
head :forbidden unless Livechat.enabled?(request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Server-side gate for the inbox. Default: development only.
|
|
33
|
+
def require_agent
|
|
34
|
+
return if Livechat.agent?(request)
|
|
35
|
+
|
|
36
|
+
render plain: 'Forbidden. Set Livechat.config.authorize_agent to grant access.',
|
|
37
|
+
status: :forbidden
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Replies are attributed to whoever config.current_user resolves. When an
|
|
41
|
+
# inbox is protected by something user-less (HTTP basic, a VPN), replies
|
|
42
|
+
# still work — anonymously, as "Support".
|
|
43
|
+
def current_agent_id
|
|
44
|
+
current_visitor_id || '0'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current_agent_label
|
|
48
|
+
label = (Livechat.config.agent_label.call(current_visitor).presence if current_visitor)
|
|
49
|
+
label || I18n.t(:team, scope: :livechat, default: 'Support')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_rate_limited
|
|
53
|
+
message = I18n.t('livechat.error_rate_limited',
|
|
54
|
+
default: 'Too many messages. Please wait a moment and try again.')
|
|
55
|
+
render json: { errors: [message] }, status: :too_many_requests
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Guests get a permanent random token — their key to the conversation
|
|
59
|
+
# across visits. Signed-in visitors are keyed by id and only need the
|
|
60
|
+
# cookie to carry a guest history into their account.
|
|
61
|
+
def visitor_token
|
|
62
|
+
cookies[:livechat_vid]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_visitor_token
|
|
66
|
+
visitor_token.presence || begin
|
|
67
|
+
token = SecureRandom.base58(24)
|
|
68
|
+
cookies.permanent[:livechat_vid] = { value: token, httponly: true, same_site: :lax }
|
|
69
|
+
token
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A guest who signed in keeps their thread: adopt cookie-token
|
|
74
|
+
# conversations into the account, then the cookie no longer matters.
|
|
75
|
+
def claim_guest_conversations
|
|
76
|
+
return unless current_visitor_id && visitor_token
|
|
77
|
+
|
|
78
|
+
Conversation.claim!(
|
|
79
|
+
visitor_token: visitor_token,
|
|
80
|
+
visitor_id: current_visitor_id,
|
|
81
|
+
visitor_label: Livechat.config.visitor_label.call(current_visitor).presence
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -1,80 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Livechat
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js, the visitor API and the
|
|
5
|
+
# attachment proxy (which serves visitors as well as agents). These stay on a
|
|
6
|
+
# plain ActionController::Base deliberately — a visitor starting a chat must
|
|
7
|
+
# not be routed through a host's admin controller, which would demand a staff
|
|
8
|
+
# session for the widget.
|
|
9
|
+
#
|
|
10
|
+
# The inbox's root is DashboardController, and that is where
|
|
11
|
+
# `config.base_controller_class` applies.
|
|
4
12
|
class ApplicationController < ActionController::Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
def livechat_agent_layout
|
|
10
|
-
Livechat.config.agent_layout
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def current_visitor
|
|
14
|
-
return @current_visitor if defined?(@current_visitor)
|
|
15
|
-
|
|
16
|
-
@current_visitor = Livechat.config.current_user.call(request)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def current_visitor_id
|
|
20
|
-
current_visitor.respond_to?(:id) ? current_visitor.id.to_s : nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def require_enabled
|
|
24
|
-
head :forbidden unless Livechat.enabled?(request)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Server-side gate for the inbox. Default: development only.
|
|
28
|
-
def require_agent
|
|
29
|
-
return if Livechat.agent?(request)
|
|
13
|
+
include RequestContext
|
|
30
14
|
|
|
31
|
-
|
|
32
|
-
status: :forbidden
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Replies are attributed to whoever config.current_user resolves. When an
|
|
36
|
-
# inbox is protected by something user-less (HTTP basic, a VPN), replies
|
|
37
|
-
# still work — anonymously, as "Support".
|
|
38
|
-
def current_agent_id
|
|
39
|
-
current_visitor_id || '0'
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def current_agent_label
|
|
43
|
-
label = (Livechat.config.agent_label.call(current_visitor).presence if current_visitor)
|
|
44
|
-
label || I18n.t(:team, scope: :livechat, default: 'Support')
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def render_rate_limited
|
|
48
|
-
message = I18n.t('livechat.error_rate_limited',
|
|
49
|
-
default: 'Too many messages. Please wait a moment and try again.')
|
|
50
|
-
render json: { errors: [message] }, status: :too_many_requests
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Guests get a permanent random token — their key to the conversation
|
|
54
|
-
# across visits. Signed-in visitors are keyed by id and only need the
|
|
55
|
-
# cookie to carry a guest history into their account.
|
|
56
|
-
def visitor_token
|
|
57
|
-
cookies[:livechat_vid]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def ensure_visitor_token
|
|
61
|
-
visitor_token.presence || begin
|
|
62
|
-
token = SecureRandom.base58(24)
|
|
63
|
-
cookies.permanent[:livechat_vid] = { value: token, httponly: true, same_site: :lax }
|
|
64
|
-
token
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# A guest who signed in keeps their thread: adopt cookie-token
|
|
69
|
-
# conversations into the account, then the cookie no longer matters.
|
|
70
|
-
def claim_guest_conversations
|
|
71
|
-
return unless current_visitor_id && visitor_token
|
|
72
|
-
|
|
73
|
-
Conversation.claim!(
|
|
74
|
-
visitor_token: visitor_token,
|
|
75
|
-
visitor_id: current_visitor_id,
|
|
76
|
-
visitor_label: Livechat.config.visitor_label.call(current_visitor).presence
|
|
77
|
-
)
|
|
78
|
-
end
|
|
15
|
+
protect_from_forgery with: :exception
|
|
79
16
|
end
|
|
80
17
|
end
|
|
@@ -4,10 +4,8 @@ module Livechat
|
|
|
4
4
|
# The inbox. Every action is gated by config.authorize_agent; any
|
|
5
5
|
# authorized teammate can read and answer any thread — it's a shared line,
|
|
6
6
|
# not an assignment queue.
|
|
7
|
-
class ConversationsController <
|
|
8
|
-
before_action :require_agent
|
|
7
|
+
class ConversationsController < DashboardController
|
|
9
8
|
before_action :set_conversation, except: %i[index index_poll]
|
|
10
|
-
layout :livechat_agent_layout
|
|
11
9
|
|
|
12
10
|
PER_PAGE = 50
|
|
13
11
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Root of the AGENT surface: the inbox and agent replies.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
|
+
# ActionController::Base, which is why `authorize_agent` exists. Point it at
|
|
8
|
+
# the controller your own admin already inherits from and the inbox picks up
|
|
9
|
+
# that stack wholesale: your layout, your helpers, your authentication, and
|
|
10
|
+
# whatever request context your before_actions establish.
|
|
11
|
+
#
|
|
12
|
+
# Only the inbox hangs off it. The widget's endpoints stay on
|
|
13
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
14
|
+
# demand a staff session from a visitor starting a chat.
|
|
15
|
+
class DashboardController < Livechat.base_controller
|
|
16
|
+
include RequestContext
|
|
17
|
+
|
|
18
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
19
|
+
# override it. So the gem only claims the layout when it owns the decision:
|
|
20
|
+
# no host base controller, or a host that named an `agent_layout` explicitly.
|
|
21
|
+
layout :livechat_agent_layout unless superclass != ActionController::Base &&
|
|
22
|
+
Livechat.config.agent_layout == Configuration::DEFAULT_AGENT_LAYOUT
|
|
23
|
+
|
|
24
|
+
before_action :require_agent
|
|
25
|
+
|
|
26
|
+
# A host base controller has configured CSRF already; declaring it twice
|
|
27
|
+
# would run the check twice.
|
|
28
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Livechat
|
|
4
|
-
class MessagesController <
|
|
5
|
-
before_action :require_agent
|
|
6
|
-
|
|
4
|
+
class MessagesController < DashboardController
|
|
7
5
|
def create
|
|
8
6
|
conversation = Conversation.find(params[:conversation_id])
|
|
9
7
|
message = conversation.post_agent_message!(
|
|
@@ -5,19 +5,18 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
keyboard submit work under strict script-src CSPs. %>
|
|
12
|
-
<%= javascript_include_tag "#{dashboard_script_path}?v=#{Livechat::Widget.dashboard_fingerprint}",
|
|
13
|
-
defer: true, nonce: true %>
|
|
8
|
+
<%# The inbox's stylesheet and script are declared by the views
|
|
9
|
+
(livechat/shared/_dashboard), not here, so they survive a host replacing
|
|
10
|
+
this layout via config.agent_layout or config.base_controller_class. %>
|
|
14
11
|
</head>
|
|
15
|
-
<body class="<%= yield(:body_class) %>">
|
|
16
|
-
<div class="
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
<body class="lvc-page <%= yield(:body_class) %>">
|
|
13
|
+
<div class="lvc-dashboard">
|
|
14
|
+
<div class="container">
|
|
15
|
+
<% if flash[:alert].present? %>
|
|
16
|
+
<p class="flash alert"><%= flash[:alert] %></p>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= yield %>
|
|
19
|
+
</div>
|
|
21
20
|
</div>
|
|
22
21
|
</body>
|
|
23
22
|
</html>
|
|
@@ -1,105 +1,107 @@
|
|
|
1
1
|
<% content_for :body_class, 'lvc-inbox' %>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<%= render layout: 'livechat/shared/dashboard' do %>
|
|
4
|
+
<h1><%= t('livechat.dashboard.title', default: 'LiveChat') %></h1>
|
|
4
5
|
|
|
5
|
-
<div class="inbox-shell <%= 'has-selected' if @selected_conversation %>">
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
<div class="inbox-shell <%= 'has-selected' if @selected_conversation %>">
|
|
7
|
+
<aside class="inbox-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
|
|
8
|
+
<div class="tabs">
|
|
9
|
+
<% Livechat::Conversation::STATUSES.each do |status| %>
|
|
10
|
+
<%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
|
|
11
|
+
<%= t("livechat.statuses.#{status}", default: status.humanize) %>
|
|
12
|
+
<span class="count"><%= @counts.fetch(status, 0) %></span>
|
|
13
|
+
<% end %>
|
|
12
14
|
<% end %>
|
|
13
|
-
|
|
14
|
-
</div>
|
|
15
|
+
</div>
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
<%= form_with url: conversations_path, method: :get, class: 'filters' do %>
|
|
18
|
+
<input type="hidden" name="status" value="<%= @status %>">
|
|
19
|
+
<input type="search" name="q" value="<%= @query %>"
|
|
20
|
+
placeholder="<%= t('livechat.dashboard.search', default: 'Search') %>"
|
|
21
|
+
aria-label="<%= t('livechat.dashboard.search', default: 'Search') %>">
|
|
22
|
+
<button type="submit"><%= t('livechat.dashboard.search', default: 'Search') %></button>
|
|
23
|
+
<% end %>
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
<%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
|
|
26
|
+
unless the agent is mid-search or mid-reply, whose typing must never be eaten. %>
|
|
27
|
+
<% cable = livechat_cable_data('livechat:inbox') %>
|
|
28
|
+
<div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
|
|
29
|
+
data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
|
|
30
|
+
@counts.fetch('open', 0)].join('-') %>"
|
|
31
|
+
<%= tag.attributes(data: cable) if cable.any? %>></div>
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
<div class="conversation-list">
|
|
34
|
+
<% if @conversations.empty? %>
|
|
35
|
+
<p class="empty"><%= t('livechat.dashboard.empty',
|
|
36
|
+
default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
|
|
37
|
+
<% else %>
|
|
38
|
+
<% @conversations.each do |conversation| %>
|
|
39
|
+
<% unread = @unread.fetch(conversation.id, 0) %>
|
|
40
|
+
<%= link_to conversations_path(status: @status, q: @query, offset: @offset,
|
|
41
|
+
conversation_id: conversation.id),
|
|
42
|
+
class: ['conversation-row',
|
|
43
|
+
('active' if @selected_conversation&.id == conversation.id),
|
|
44
|
+
('unread' if unread.positive?)].compact do %>
|
|
45
|
+
<span class="conversation-main">
|
|
46
|
+
<span class="conversation-topline">
|
|
47
|
+
<span class="conversation-name"><%= conversation.display_name %></span>
|
|
48
|
+
<span class="muted conversation-time">
|
|
49
|
+
<% if conversation.last_activity_at %>
|
|
50
|
+
<%= time_ago_in_words(conversation.last_activity_at) %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</span>
|
|
53
|
+
</span>
|
|
54
|
+
<span class="muted conversation-meta">
|
|
55
|
+
<span class="case-id">#<%= conversation.id %></span>
|
|
56
|
+
<% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
|
|
57
|
+
<span><%= conversation.visitor_email %></span>
|
|
50
58
|
<% end %>
|
|
51
59
|
</span>
|
|
60
|
+
<span class="muted conversation-preview"><%= conversation.last_message_preview %></span>
|
|
52
61
|
</span>
|
|
53
|
-
<span class="
|
|
54
|
-
|
|
55
|
-
|
|
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">
|
|
62
|
-
<% if unread.positive? %>
|
|
63
|
-
<span class="badge unread-count"><%= unread %></span>
|
|
64
|
-
<% end %>
|
|
65
|
-
<span class="avatars">
|
|
66
|
-
<% @agents.fetch(conversation.id, []).each do |label| %>
|
|
67
|
-
<%= livechat_agent_avatar(label) %>
|
|
62
|
+
<span class="conversation-side">
|
|
63
|
+
<% if unread.positive? %>
|
|
64
|
+
<span class="badge unread-count"><%= unread %></span>
|
|
68
65
|
<% end %>
|
|
66
|
+
<span class="avatars">
|
|
67
|
+
<% @agents.fetch(conversation.id, []).each do |label| %>
|
|
68
|
+
<%= livechat_agent_avatar(label) %>
|
|
69
|
+
<% end %>
|
|
70
|
+
</span>
|
|
69
71
|
</span>
|
|
70
|
-
|
|
72
|
+
<% end %>
|
|
71
73
|
<% end %>
|
|
72
74
|
<% end %>
|
|
73
|
-
|
|
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>
|
|
75
|
+
</div>
|
|
89
76
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
default: 'Open a chat from the list to read context and reply.') %></p>
|
|
77
|
+
<div class="pager">
|
|
78
|
+
<% if @offset.positive? %>
|
|
79
|
+
<%= link_to t('livechat.dashboard.newer', default: 'Newer'),
|
|
80
|
+
conversations_path(status: @status, q: @query,
|
|
81
|
+
offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
|
|
82
|
+
<% end %>
|
|
83
|
+
<% if @more %>
|
|
84
|
+
<%= link_to t('livechat.dashboard.older', default: 'Older'),
|
|
85
|
+
conversations_path(status: @status, q: @query,
|
|
86
|
+
offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
|
|
87
|
+
<% end %>
|
|
102
88
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
</aside>
|
|
90
|
+
|
|
91
|
+
<main class="inbox-thread" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
|
|
92
|
+
<% if @selected_conversation %>
|
|
93
|
+
<p class="mobile-back">
|
|
94
|
+
<%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}",
|
|
95
|
+
conversations_path(status: @status, q: @query, offset: @offset) %>
|
|
96
|
+
</p>
|
|
97
|
+
<%= render 'thread_panel', conversation: @selected_conversation, messages: @messages, autofocus: false %>
|
|
98
|
+
<% else %>
|
|
99
|
+
<div class="empty-state">
|
|
100
|
+
<h2><%= t('livechat.dashboard.select_conversation', default: 'Select a conversation') %></h2>
|
|
101
|
+
<p class="muted"><%= t('livechat.dashboard.select_conversation_hint',
|
|
102
|
+
default: 'Open a chat from the list to read context and reply.') %></p>
|
|
103
|
+
</div>
|
|
104
|
+
<% end %>
|
|
105
|
+
</main>
|
|
106
|
+
</div>
|
|
107
|
+
<% end %>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<%# Fill the viewport and scroll only the thread (see .lvc-convo in the layout). %>
|
|
2
2
|
<% content_for :body_class, 'lvc-convo' %>
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
<%= render layout: 'livechat/shared/dashboard' do %>
|
|
5
|
+
<p class="breadcrumb">
|
|
6
|
+
<%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}", conversations_path %>
|
|
7
|
+
</p>
|
|
7
8
|
|
|
8
|
-
<%= render 'thread_panel', conversation: @conversation, messages: @messages %>
|
|
9
|
+
<%= render 'thread_panel', conversation: @conversation, messages: @messages %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# The inbox's own shell, rendered by the views rather than the layout.
|
|
2
|
+
That is deliberate: `config.agent_layout` and `config.base_controller_class`
|
|
3
|
+
both let a host replace the layout, and assets declared in a layout the host
|
|
4
|
+
replaces simply vanish — the inbox then renders unstyled with its thread
|
|
5
|
+
polling and keyboard submit dead. Declaring them here means every layout
|
|
6
|
+
works, host or gem.
|
|
7
|
+
|
|
8
|
+
The `lvc-dashboard` wrapper is what scopes dashboard.css (see the comment at
|
|
9
|
+
the top of that file), so it has to enclose the content, not precede it. %>
|
|
10
|
+
<%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Livechat::Widget.dashboard_stylesheet_fingerprint}",
|
|
11
|
+
'data-turbo-track': 'reload' %>
|
|
12
|
+
<%# Same-origin script instead of inline handlers, so thread polling and
|
|
13
|
+
keyboard submit work under strict script-src CSPs. %>
|
|
14
|
+
<%= javascript_include_tag "#{dashboard_script_path}?v=#{Livechat::Widget.dashboard_fingerprint}",
|
|
15
|
+
defer: true, nonce: true %>
|
|
16
|
+
<div class="lvc-dashboard">
|
|
17
|
+
<%= yield %>
|
|
18
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -17,12 +17,17 @@ Livechat::Engine.routes.draw do
|
|
|
17
17
|
|
|
18
18
|
# Message attachments, gated by the engine (never a public blob URL). One
|
|
19
19
|
# route for both sides — the controller decides whether you're the visitor
|
|
20
|
-
# who owns the thread or an agent.
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# who owns the thread or an agent. Declared above the conversations catch-all,
|
|
21
|
+
# which is what keeps it from being shadowed — no id constraint needed, and a
|
|
22
|
+
# digits-only one would have made the tables bigint-only (see below).
|
|
23
|
+
get 'attachments/:id', to: 'attachments#show', as: :attachment
|
|
23
24
|
|
|
24
25
|
# The inbox. Flat, human URLs: the mount path IS the conversation list.
|
|
25
|
-
|
|
26
|
+
# No `id: /\d+/` constraint: it made these routes bigint-only, which forced the
|
|
27
|
+
# tables to be bigint too, and a uuid-keyed host could then never attach a file
|
|
28
|
+
# (its active_storage_attachments.record_id is a uuid column). Every fixed-name
|
|
29
|
+
# route above is declared first, so ordering already disambiguates.
|
|
30
|
+
resources :conversations, path: '', only: %i[index show] do
|
|
26
31
|
collection do
|
|
27
32
|
get :poll, action: :index_poll
|
|
28
33
|
end
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rails/generators'
|
|
4
4
|
require 'rails/generators/active_record'
|
|
5
|
+
require_relative '../migration_helpers'
|
|
5
6
|
|
|
6
7
|
module Livechat
|
|
7
8
|
module Generators
|
|
8
9
|
class InstallGenerator < Rails::Generators::Base
|
|
9
10
|
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationHelpers
|
|
10
12
|
|
|
11
13
|
source_root File.expand_path('templates', __dir__)
|
|
12
14
|
|
|
@@ -32,12 +34,6 @@ module Livechat
|
|
|
32
34
|
say 'Optional: run `bin/rails livechat:seed_demo` for sample conversations.'
|
|
33
35
|
say "Set config.mailer_from + config.agent_emails to hear about new messages by email.\n"
|
|
34
36
|
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def migration_version
|
|
39
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
40
|
-
end
|
|
41
37
|
end
|
|
42
38
|
end
|
|
43
39
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateLivechatTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def change
|
|
5
|
-
create_table :livechat_conversations do |t|
|
|
5
|
+
create_table :livechat_conversations<%= primary_key_type_option %> do |t|
|
|
6
6
|
t.string :visitor_token
|
|
7
7
|
t.string :visitor_id
|
|
8
8
|
t.string :visitor_label
|
|
@@ -20,8 +20,10 @@ class CreateLivechatTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
20
20
|
add_index :livechat_conversations, :status
|
|
21
21
|
add_index :livechat_conversations, :last_activity_at
|
|
22
22
|
|
|
23
|
-
create_table :livechat_messages do |t|
|
|
24
|
-
|
|
23
|
+
create_table :livechat_messages<%= primary_key_type_option %> do |t|
|
|
24
|
+
# index: false — the (conversation_id, id) index below already covers
|
|
25
|
+
# lookups by conversation_id, being a leftmost prefix of it.
|
|
26
|
+
t.references :conversation, null: false, index: false<%= foreign_key_type_option %>
|
|
25
27
|
t.string :author_type, null: false
|
|
26
28
|
t.string :agent_id
|
|
27
29
|
t.string :agent_label
|