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.
- checksums.yaml +4 -4
- data/AGENTS.md +173 -0
- data/CHANGELOG.md +45 -0
- data/README.md +6 -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 +224 -231
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat.rb +7 -0
- metadata +6 -1
|
@@ -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
|
|
@@ -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 =
|
|
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 }
|