livechat 0.7.1 → 0.8.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.
@@ -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
@@ -15,6 +15,14 @@ Livechat.configure do |config|
15
15
  #
16
16
  # Render the inbox inside your app's admin layout. Default: the gem's
17
17
  # standalone inbox layout.
18
+ # Two ways to put the inbox inside an admin you already have.
19
+ #
20
+ # The whole stack — your layout, helpers, authentication and any request
21
+ # context your before_actions set up. Only the inbox inherits it; the
22
+ # widget's endpoints stay public, so this cannot gate a visitor's chat.
23
+ # config.base_controller_class = "Admin::BaseController"
24
+ #
25
+ # Or the layout alone:
18
26
  # config.agent_layout = "admin/application"
19
27
 
20
28
  # Resolve the current user (optional). Return an object responding to #id,
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids has a uuid
15
+ # `active_storage_attachments.record_id`, so a bigint table here could
16
+ # never take a message attachment: the foreign key has nowhere to point
17
+ # and `attach` raises NotNullViolation.
18
+ #
19
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
20
+ # migrations do, so a host that set it once gets consistent tables from
21
+ # all of them.
22
+ #
23
+ # Rendered as options rather than bare values, because a template is
24
+ # expanded at generate time: emitting the method name would put
25
+ # `id: primary_key_type` in the migration, where nothing defines it.
26
+ def primary_key_type_option
27
+ primary_key_type ? ", id: :#{primary_key_type}" : ''
28
+ end
29
+
30
+ # The engine's own foreign key (messages -> conversations) has to match
31
+ # whatever the referenced table's primary key became, or the reference
32
+ # column is a bigint pointing at a uuid.
33
+ def foreign_key_type_option
34
+ primary_key_type ? ", type: :#{primary_key_type}" : ''
35
+ end
36
+
37
+ def primary_key_type
38
+ config = Rails.configuration.generators
39
+ config.options[config.orm][:primary_key_type]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -5,6 +5,10 @@ module Livechat
5
5
  # works with zero configuration; the hooks below let an app decide who can
6
6
  # chat, who answers, and how everyone is named.
7
7
  class Configuration
8
+ # The gem's own agent layout. Compared against, so DashboardController can
9
+ # tell "the host left this alone" from "the host chose this".
10
+ DEFAULT_AGENT_LAYOUT = 'livechat/application'
11
+
8
12
  # Shown in the widget header and in notification emails. nil resolves to
9
13
  # the Rails application name.
10
14
  attr_accessor :app_name
@@ -22,6 +26,21 @@ module Livechat
22
26
  # inside your app's admin shell, e.g. "admin/application".
23
27
  attr_accessor :agent_layout
24
28
 
29
+ # The controller the INBOX inherits from, as a String so it resolves lazily
30
+ # rather than at config time. Default: a plain 'ActionController::Base',
31
+ # where `authorize_agent` is the only gate.
32
+ #
33
+ # Name the controller your own admin already inherits from and the inbox
34
+ # adopts that whole stack — layout, helpers, authentication, and any request
35
+ # context your before_actions set up. `agent_layout` covers only the layout,
36
+ # which leaves a host layout calling its own helpers to raise NameError under
37
+ # the engine's isolated namespace.
38
+ #
39
+ # Only the inbox uses it. The widget's endpoints stay on the engine's own
40
+ # public controller, so an admin base controller here can never demand a
41
+ # staff session from a visitor starting a chat.
42
+ attr_accessor :base_controller_class
43
+
25
44
  # Resolve the current user (optional). Return an object responding to
26
45
  # #id, or nil. Receives the request. Signed-in users keep one conversation
27
46
  # across devices; guests are tracked with a cookie.
@@ -109,7 +128,8 @@ module Livechat
109
128
  @app_name = nil
110
129
  @enabled = ->(_request) { true }
111
130
  @authorize_agent = ->(_request) { Rails.env.development? }
112
- @agent_layout = 'livechat/application'
131
+ @agent_layout = DEFAULT_AGENT_LAYOUT
132
+ @base_controller_class = 'ActionController::Base'
113
133
  @current_user = ->(_request) {}
114
134
  @visitor_label = ->(user) { user.try(:name).presence || user.try(:email).presence || user.to_s }
115
135
  @agent_label = ->(user) { user.try(:name).presence || user.try(:email).presence || user.to_s }