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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57a8fc1c73b96c5e4d063c004d7d9e22eb90f888a38bbdd11d568c9088cb2660
4
- data.tar.gz: 6a1d40df302ab6ad64c6f94b5c98c8e29a9d2c7f4bc6f53e036a0a2fce5d5ea8
3
+ metadata.gz: 6007e2a9721aeb42f80cee9f9e5609adff1ae7edd787f9329efd74d1d92f94b5
4
+ data.tar.gz: 0d9769f92ead60fbb908447204bc7a9dabbf33f2ef8b1eb4b4f501e0df29e7b5
5
5
  SHA512:
6
- metadata.gz: e366d6123965f80394da3fee7555d6f312dda5ff315887b168e3cc0eccd2beb89771c9da864d8eead4983b841cb1852566196a32680890e08112f44cf7779022
7
- data.tar.gz: 216f73d82310d38bcb53345f95dfe3d3b0b22a38785ba8b2ab05f2d8dce2877cd2e22e77fc2b8e1da567fa2db0ca5c74524ba1f40056898172a7a60906fadd2b
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
- protect_from_forgery with: :exception
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
- render plain: 'Forbidden. Set Livechat.config.authorize_agent to grant access.',
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 < ApplicationController
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 < ApplicationController
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
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Livechat::Widget.dashboard_stylesheet_fingerprint}",
9
- 'data-turbo-track': 'reload' %>
10
- <%# Same-origin script instead of inline handlers, so thread polling and
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="container">
17
- <% if flash[:alert].present? %>
18
- <p class="flash alert"><%= flash[:alert] %></p>
19
- <% end %>
20
- <%= yield %>
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
- <h1><%= t('livechat.dashboard.title', default: 'LiveChat') %></h1>
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
- <aside class="inbox-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
7
- <div class="tabs">
8
- <% Livechat::Conversation::STATUSES.each do |status| %>
9
- <%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
10
- <%= t("livechat.statuses.#{status}", default: status.humanize) %>
11
- <span class="count"><%= @counts.fetch(status, 0) %></span>
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
- <% end %>
14
- </div>
15
+ </div>
15
16
 
16
- <%= form_with url: conversations_path, method: :get, class: 'filters' do %>
17
- <input type="hidden" name="status" value="<%= @status %>">
18
- <input type="search" name="q" value="<%= @query %>"
19
- placeholder="<%= t('livechat.dashboard.search', default: 'Search') %>"
20
- aria-label="<%= t('livechat.dashboard.search', default: 'Search') %>">
21
- <button type="submit"><%= t('livechat.dashboard.search', default: 'Search') %></button>
22
- <% end %>
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
- <%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
25
- unless the agent is mid-search or mid-reply, whose typing must never be eaten. %>
26
- <% cable = livechat_cable_data('livechat:inbox') %>
27
- <div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
28
- data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
29
- @counts.fetch('open', 0)].join('-') %>"
30
- <%= tag.attributes(data: cable) if cable.any? %>></div>
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
- <div class="conversation-list">
33
- <% if @conversations.empty? %>
34
- <p class="empty"><%= t('livechat.dashboard.empty',
35
- default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
36
- <% else %>
37
- <% @conversations.each do |conversation| %>
38
- <% unread = @unread.fetch(conversation.id, 0) %>
39
- <%= link_to conversations_path(status: @status, q: @query, offset: @offset,
40
- conversation_id: conversation.id),
41
- class: ['conversation-row',
42
- ('active' if @selected_conversation&.id == conversation.id),
43
- ('unread' if unread.positive?)].compact do %>
44
- <span class="conversation-main">
45
- <span class="conversation-topline">
46
- <span class="conversation-name"><%= conversation.display_name %></span>
47
- <span class="muted conversation-time">
48
- <% if conversation.last_activity_at %>
49
- <%= time_ago_in_words(conversation.last_activity_at) %>
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="muted conversation-meta">
54
- <span class="case-id">#<%= conversation.id %></span>
55
- <% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
56
- <span><%= conversation.visitor_email %></span>
57
- <% end %>
58
- </span>
59
- <span class="muted conversation-preview"><%= conversation.last_message_preview %></span>
60
- </span>
61
- <span class="conversation-side">
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
- </span>
72
+ <% end %>
71
73
  <% end %>
72
74
  <% end %>
73
- <% end %>
74
- </div>
75
-
76
- <div class="pager">
77
- <% if @offset.positive? %>
78
- <%= link_to t('livechat.dashboard.newer', default: 'Newer'),
79
- conversations_path(status: @status, q: @query,
80
- offset: [@offset - Livechat::ConversationsController::PER_PAGE, 0].max) %>
81
- <% end %>
82
- <% if @more %>
83
- <%= link_to t('livechat.dashboard.older', default: 'Older'),
84
- conversations_path(status: @status, q: @query,
85
- offset: @offset + Livechat::ConversationsController::PER_PAGE) %>
86
- <% end %>
87
- </div>
88
- </aside>
75
+ </div>
89
76
 
90
- <main class="inbox-thread" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
91
- <% if @selected_conversation %>
92
- <p class="mobile-back">
93
- <%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}",
94
- conversations_path(status: @status, q: @query, offset: @offset) %>
95
- </p>
96
- <%= render 'thread_panel', conversation: @selected_conversation, messages: @messages, autofocus: false %>
97
- <% else %>
98
- <div class="empty-state">
99
- <h2><%= t('livechat.dashboard.select_conversation', default: 'Select a conversation') %></h2>
100
- <p class="muted"><%= t('livechat.dashboard.select_conversation_hint',
101
- default: 'Open a chat from the list to read context and reply.') %></p>
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
- <% end %>
104
- </main>
105
- </div>
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
- <p class="breadcrumb">
5
- <%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}", conversations_path %>
6
- </p>
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. Above the conversations catch-all, and
21
- # id-constrained so it never shadows a numeric conversation path.
22
- get 'attachments/:id', to: 'attachments#show', as: :attachment, constraints: { id: /\d+/ }
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
- resources :conversations, path: '', only: %i[index show], constraints: { id: /\d+/ } do
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
- t.references :conversation, null: false
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