livechat 0.7.2 → 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.
@@ -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 }
@@ -1,233 +1,226 @@
1
- :root {
1
+ /* Dashboard styles.
2
+ *
3
+ * Three layers, and the split is what lets a host replace the layout:
4
+ *
5
+ * :root custom properties, all `--lvc-` prefixed so they can
6
+ * neither overwrite a host's nor be overwritten by them.
7
+ * .lvc-page the page frame (was bare html/body). Only the gem's own
8
+ * layout puts this class on <body>, so inside a host admin
9
+ * these rules simply do not apply.
10
+ * .lvc-dashboard everything else, nested. Names like .container, .card
11
+ * and .tabs are confined to the dashboard's own markup, so
12
+ * loading this file inside a host cannot restyle its chrome.
13
+ *
14
+ * Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
15
+ */
16
+
17
+
18
+ :root {
2
19
  color-scheme: light dark;
3
- --bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
4
- --border: #e5e7eb; --accent: #2563eb; --accent-text: #fff;
5
- --open: #16a34a; --resolved: #6b7280; --danger: #dc2626;
6
- }
7
- @media (prefers-color-scheme: dark) {
8
- :root {
9
- --bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
10
- --border: #2a313a; --accent: #3b82f6;
11
- }
12
- }
13
- * { box-sizing: border-box; }
14
- body { margin: 0; background: var(--bg); color: var(--text);
15
- font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
16
- a { color: var(--accent); text-decoration: none; }
17
- a:hover { text-decoration: underline; }
18
- .container { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
19
- h1 { font-size: 22px; margin: 0 0 16px; }
20
- .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
21
- .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
22
- .tabs a.active { color: var(--text); font-weight: 600; border: 1px solid var(--border);
23
- border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px; }
24
- .tabs a:hover { text-decoration: none; color: var(--text); }
25
- .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
26
- background: var(--border); font-size: 12px; text-align: center; }
27
- .filters { margin-bottom: 16px; display: flex; gap: 8px; }
28
- .filters input[type=search] { flex: 1; max-width: 320px; padding: 6px 10px; border: 1px solid var(--border);
29
- border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; }
30
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
31
- .card.pad { padding: 16px; }
32
- table { width: 100%; border-collapse: collapse; }
33
- th, td { padding: 10px 14px; text-align: left; vertical-align: top; }
34
- th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
35
- border-bottom: 1px solid var(--border); }
36
- tr + tr td { border-top: 1px solid var(--border); }
37
- tr.unread td { font-weight: 600; }
38
- .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
39
- color: #fff; white-space: nowrap; }
40
- .badge.status-open { background: var(--open); }
41
- .badge.status-resolved { background: var(--resolved); }
42
- .badge.unread-count { background: var(--danger); }
43
- .muted { color: var(--muted); font-size: 13px; }
44
- .pager { margin-top: 16px; display: flex; gap: 16px; }
45
- .empty { padding: 48px 16px; text-align: center; color: var(--muted); }
46
- .breadcrumb { margin-bottom: 12px; font-size: 13px; }
47
- .flash { margin: 0 0 16px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border);
48
- background: var(--surface); }
49
- .flash.alert { color: var(--danger); border-color: var(--danger); }
50
- .head-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
51
- .head-row h1 { margin: 0; flex: 1; }
52
- .head-row form { display: inline; }
53
- .status-toggle-form { flex: 0 0 auto; }
54
- .status-toggle { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 176px;
55
- min-height: 36px; padding: 3px; border-radius: 999px; border: 1px solid var(--border);
56
- background: var(--bg); color: var(--muted); overflow: hidden; }
57
- .status-toggle::before { content: ""; position: absolute; z-index: 0; top: 3px; bottom: 3px;
58
- width: calc(50% - 3px); border-radius: 999px; background: var(--surface);
59
- box-shadow: 0 1px 2px rgba(15, 23, 42, .12); transition: left .16s ease; }
60
- .status-toggle.is-open::before { left: 3px; }
61
- .status-toggle.is-resolved::before { left: 50%; }
62
- .status-toggle span { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center;
63
- min-width: 0; padding: 0 10px; font-size: 13px; font-weight: 700; white-space: nowrap; }
64
- .status-toggle.is-open span:first-child { color: var(--open); }
65
- .status-toggle.is-resolved span:last-child { color: var(--text); }
66
- .status-toggle:hover { text-decoration: none; color: var(--text); }
67
- button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
68
- background: var(--surface); color: var(--text); font: inherit; }
69
- button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
70
- .case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
71
- .avatars { display: inline-flex; }
72
- .avatars .avatar { width: 26px; height: 26px; border-radius: 50%; display: inline-flex;
73
- align-items: center; justify-content: center; font-size: 10px; font-weight: 700;
74
- color: #fff; border: 2px solid var(--surface); cursor: default; }
75
- .avatars .avatar + .avatar { margin-left: -8px; }
76
- .avatar.color-0 { background: #2563eb; } .avatar.color-1 { background: #16a34a; }
77
- .avatar.color-2 { background: #d97706; } .avatar.color-3 { background: #dc2626; }
78
- .avatar.color-4 { background: #7c3aed; } .avatar.color-5 { background: #0891b2; }
79
- .context-line { margin: -6px 0 16px; overflow-wrap: anywhere; }
80
- .thread { display: flex; flex-direction: column; gap: 4px; padding: 16px; }
81
- .thread .who { font-size: 12px; color: var(--muted); margin: 10px 4px 2px; }
82
- .thread .who.visitor { text-align: left; }
83
- .thread .who.agent { text-align: right; }
84
- .thread .msg { max-width: 78%; padding: 8px 12px; border-radius: 14px; white-space: pre-wrap;
85
- overflow-wrap: anywhere; }
86
- .thread .msg.visitor { align-self: flex-start; background: var(--bg); border: 1px solid var(--border);
87
- border-bottom-left-radius: 4px; }
88
- .thread .msg.agent { align-self: flex-end; background: var(--accent); color: var(--accent-text);
89
- border-bottom-right-radius: 4px; }
90
- .thread .system { align-self: center; color: var(--muted); font-size: 12px; margin: 8px 0; }
91
- .thread .typing { align-self: flex-start; color: var(--muted); font-size: 12px;
92
- font-style: italic; margin: 8px 4px 2px; }
93
- .thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
94
- .thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
95
- .thread .msg .att-audio { display: block; width: 280px; max-width: 100%; height: 36px; }
96
- .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px;
97
- background: rgba(0,0,0,.06); color: inherit; font-size: 13px;
98
- overflow-wrap: anywhere; }
99
- .thread .msg.agent .att-file { background: rgba(255,255,255,.2); }
100
- #new-messages { margin: 12px 0 0; }
101
- #new-messages a { font-weight: 600; }
102
- /* Unified composer box (matches the visitor widget): the reply on top, a
103
- toolbar row below (tools left, send right), all inside one bordered box
104
- that lights up on focus. */
105
- .reply { display: flex; flex-direction: column; gap: 6px; margin: 10px 12px; padding: 8px 10px;
106
- border: 1px solid var(--border); border-radius: 14px; background: var(--bg); }
107
- .reply:focus-within { border-color: var(--accent); }
108
- .reply textarea { -webkit-appearance: none; appearance: none; border: none; background: none;
109
- resize: none; outline: none; font: inherit; color: var(--text);
110
- padding: 4px 4px 0; min-height: 40px; max-height: 160px; overflow-y: auto; }
111
- .reply input[type=file].reply-file { font-size: 12px; color: var(--muted); }
112
- .reply-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
113
- .reply-tools { display: flex; align-items: center; gap: 2px; }
114
- .reply .attach { width: 32px; height: 32px; padding: 0; border: none; background: none;
115
- color: var(--muted); border-radius: 8px; cursor: pointer;
116
- display: flex; align-items: center; justify-content: center; }
117
- .reply .attach:hover { background: var(--border); color: var(--text); }
118
- .reply .attach.recording-on { color: var(--danger); }
119
- .reply.is-recording .attach { display: none; }
120
- .reply .chips { display: flex; flex-wrap: wrap; gap: 6px; }
121
- .reply .chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
122
- padding: 4px 6px 4px 10px; border: 1px solid var(--border); border-radius: 999px;
123
- background: var(--surface); font-size: 12px; }
124
- .reply .chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
125
- .reply .chip-x { padding: 0 2px; border: none; background: none; color: var(--muted);
126
- font-size: 16px; line-height: 1; cursor: pointer; }
127
- .reply .chip-x:hover { color: var(--text); }
128
- .reply .recording { display: inline-flex; align-items: center; gap: 8px; height: 32px;
129
- padding: 0 4px 0 8px; border: 1px solid var(--border); border-radius: 999px;
130
- background: var(--surface); color: var(--muted); font-size: 12px;
131
- white-space: nowrap; }
132
- .reply .recording[hidden] { display: none; }
133
- .reply .recording-status { display: inline-grid; grid-template-columns: 8px 24px 34px;
134
- align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
135
- .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger);
136
- box-shadow: 0 0 0 3px rgba(220,38,38,.12); }
137
- .reply .recording-rec { font-size: 10px; font-weight: 700; letter-spacing: .04em; color: var(--danger); }
138
- .reply .recording-actions { display: flex; align-items: center; gap: 4px; }
139
- .reply .recording-actions button { width: 24px; height: 24px; padding: 0; border: none;
140
- border-radius: 50%; background: none; color: var(--muted);
141
- display: flex; align-items: center; justify-content: center; }
142
- .reply .recording-actions button:hover { background: var(--border); color: var(--text); }
143
- .reply .recording-actions .stop-recording { background: var(--danger); color: #fff; }
144
- .reply .recording-actions .stop-recording:hover { background: #b91c1c; color: #fff; }
145
- .reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%;
146
- background: var(--accent); color: var(--accent-text); cursor: pointer;
147
- display: flex; align-items: center; justify-content: center; }
148
- /* Desktop inbox: a durable conversation list on the left and the active
149
- thread on the right, with each pane owning its own scroll. */
150
- .lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
151
- .lvc-inbox { overflow: hidden; }
152
- .lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
153
- .lvc-inbox .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
154
- .inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr;
155
- gap: 16px; }
156
- .inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
157
- background: var(--surface); border: 1px solid var(--border);
158
- border-radius: 8px; overflow: hidden; }
159
- .inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
160
- border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
161
- .inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
162
- justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px;
163
- border-radius: 8px; color: var(--muted); font-weight: 600; }
164
- .inbox-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
165
- .inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0;
166
- height: 2px; border-radius: 999px; background: var(--accent); }
167
- .inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
168
- .inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
169
- .inbox-sidebar .tabs a.active .count { background: var(--border); }
170
- .inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
171
- .inbox-sidebar .filters input[type=search] { max-width: none; }
172
- .conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
173
- .conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
174
- color: var(--text); border-bottom: 1px solid var(--border); }
175
- .conversation-row:hover { text-decoration: none; background: var(--bg); }
176
- .conversation-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
177
- box-shadow: inset 3px 0 0 var(--accent); }
178
- .conversation-row.unread .conversation-name, .conversation-row.unread .conversation-preview { font-weight: 700; }
179
- .conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
180
- .conversation-topline, .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
181
- .conversation-name, .conversation-preview, .conversation-meta span { overflow: hidden; text-overflow: ellipsis;
182
- white-space: nowrap; }
183
- .conversation-name { font-weight: 600; }
184
- .conversation-time { margin-left: auto; white-space: nowrap; }
185
- .conversation-preview { display: block; }
186
- .conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
187
- gap: 8px; padding-top: 1px; }
188
- .conversation-side .avatars { min-height: 26px; }
189
- .inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
190
- .inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
191
- .inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
192
- padding: 0; }
193
- .conversation-panel .head-row, .conversation-panel .context-line { flex: 0 0 auto; }
194
- .conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
195
- border-radius: 8px; }
196
- .conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
197
- .conversation-panel .reply { flex: 0 0 auto; }
198
- .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
199
- .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
200
- .empty-state p { margin: 0; }
201
- .mobile-back { display: none; }
202
- /* Conversation page: fill the viewport and scroll the THREAD, not the
203
- page — so a reply never jumps the page, and the composer stays put. */
204
- .lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
205
- .lvc-convo { overflow: hidden; }
206
- .lvc-convo .container { display: flex; flex-direction: column; padding-bottom: 16px; }
207
- .lvc-convo .breadcrumb, .lvc-convo .head-row, .lvc-convo .context-line,
20
+ --lvc-bg: #f6f7f9; --lvc-surface: #fff; --lvc-text: #1c2024; --lvc-muted: #6b7280;
21
+ --lvc-border: #e5e7eb; --lvc-accent: #2563eb; --lvc-accent-text: #fff;
22
+ --lvc-open: #16a34a; --lvc-resolved: #6b7280; --lvc-danger: #dc2626;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root {
26
+ --lvc-bg: #111418; --lvc-surface: #1a1f26; --lvc-text: #e6e8ea; --lvc-muted: #9aa2ab;
27
+ --lvc-border: #2a313a; --lvc-accent: #3b82f6;
28
+ }
29
+ }
30
+ @media (max-width: 760px) {
31
+ .lvc-page { font-size: 14px; }
32
+ .lvc-inbox { overflow: auto; }
33
+ .lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
34
+ .lvc-inbox .container { padding-bottom: 10px; }
35
+ .lvc-dashboard {
36
+ & .container { padding: 14px 10px 24px; }
37
+ & .inbox-shell { display: block; min-height: calc(100vh - 62px); }
38
+ & .inbox-sidebar,
39
+ & .inbox-thread { min-height: calc(100vh - 62px); }
40
+ & .inbox-shell.has-selected .inbox-sidebar { display: none; }
41
+ & .inbox-shell:not(.has-selected) .inbox-thread { display: none; }
42
+ & .conversation-list { overflow: visible; }
43
+ & .conversation-row { padding: 12px; }
44
+ & .conversation-meta { display: none; }
45
+ & .conversation-panel { min-height: calc(100vh - 64px); }
46
+ & .inbox-thread .conversation-panel { padding: 0; }
47
+ & .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
48
+ & .conversation-panel .head-row h1 { font-size: 18px; }
49
+ & .status-toggle { width: 152px; min-height: 34px; }
50
+ & .status-toggle span { padding: 0 7px; font-size: 12px; }
51
+ & .thread .msg { max-width: 88%; }
52
+ }
53
+ }
54
+
55
+ /* Page frame the gem's own layout only. */
56
+ .lvc-page { margin: 0; background: var(--lvc-bg); color: var(--lvc-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
57
+ .lvc-inbox { overflow: hidden; }
58
+ .lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
59
+ .lvc-inbox .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
60
+ .lvc-convo { overflow: hidden; }
61
+ .lvc-convo .container { display: flex; flex-direction: column; padding-bottom: 16px; }
62
+ .lvc-convo .breadcrumb, .lvc-convo .head-row, .lvc-convo .context-line,
208
63
  .lvc-convo .flash { flex: 0 0 auto; }
209
- .lvc-convo .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
210
- .lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
211
- .lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
212
- .lvc-convo .reply { flex: 0 0 auto; }
213
- @media (max-width: 760px) {
214
- body { font-size: 14px; }
215
- .container { padding: 14px 10px 24px; }
216
- .lvc-inbox { overflow: auto; }
217
- .lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
218
- .lvc-inbox .container { padding-bottom: 10px; }
219
- .inbox-shell { display: block; min-height: calc(100vh - 62px); }
220
- .inbox-sidebar, .inbox-thread { min-height: calc(100vh - 62px); }
221
- .inbox-shell.has-selected .inbox-sidebar { display: none; }
222
- .inbox-shell:not(.has-selected) .inbox-thread { display: none; }
223
- .conversation-list { overflow: visible; }
224
- .conversation-row { padding: 12px; }
225
- .conversation-meta { display: none; }
226
- .conversation-panel { min-height: calc(100vh - 64px); }
227
- .inbox-thread .conversation-panel { padding: 0; }
228
- .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
229
- .conversation-panel .head-row h1 { font-size: 18px; }
230
- .status-toggle { width: 152px; min-height: 34px; }
231
- .status-toggle span { padding: 0 7px; font-size: 12px; }
232
- .thread .msg { max-width: 88%; }
233
- }
64
+ .lvc-convo .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
65
+ .lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
66
+ .lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
67
+ .lvc-convo .reply { flex: 0 0 auto; }
68
+
69
+ /* Dashboard components any layout. */
70
+ .lvc-dashboard {
71
+ & * { box-sizing: border-box; }
72
+ & a { color: var(--lvc-accent); text-decoration: none; }
73
+ & a:hover { text-decoration: underline; }
74
+ & .container { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
75
+ & h1 { font-size: 22px; margin: 0 0 16px; }
76
+ & .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--lvc-border); margin-bottom: 16px; flex-wrap: wrap; }
77
+ & .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--lvc-muted); }
78
+ & .tabs a.active { color: var(--lvc-text); font-weight: 600; border: 1px solid var(--lvc-border); border-bottom: 2px solid var(--lvc-surface); background: var(--lvc-surface); margin-bottom: -1px; }
79
+ & .tabs a:hover { text-decoration: none; color: var(--lvc-text); }
80
+ & .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px; background: var(--lvc-border); font-size: 12px; text-align: center; }
81
+ & .filters { margin-bottom: 16px; display: flex; gap: 8px; }
82
+ & .filters input[type=search] { flex: 1; max-width: 320px; padding: 6px 10px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; }
83
+ & .card { background: var(--lvc-surface); border: 1px solid var(--lvc-border); border-radius: 12px; overflow: hidden; }
84
+ & .card.pad { padding: 16px; }
85
+ & table { width: 100%; border-collapse: collapse; }
86
+ & th,
87
+ & td { padding: 10px 14px; text-align: left; vertical-align: top; }
88
+ & th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--lvc-muted); border-bottom: 1px solid var(--lvc-border); }
89
+ & tr + tr td { border-top: 1px solid var(--lvc-border); }
90
+ & tr.unread td { font-weight: 600; }
91
+ & .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
92
+ & .badge.status-open { background: var(--lvc-open); }
93
+ & .badge.status-resolved { background: var(--lvc-resolved); }
94
+ & .badge.unread-count { background: var(--lvc-danger); }
95
+ & .muted { color: var(--lvc-muted); font-size: 13px; }
96
+ & .pager { margin-top: 16px; display: flex; gap: 16px; }
97
+ & .empty { padding: 48px 16px; text-align: center; color: var(--lvc-muted); }
98
+ & .breadcrumb { margin-bottom: 12px; font-size: 13px; }
99
+ & .flash { margin: 0 0 16px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--lvc-border); background: var(--lvc-surface); }
100
+ & .flash.alert { color: var(--lvc-danger); border-color: var(--lvc-danger); }
101
+ & .head-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
102
+ & .head-row h1 { margin: 0; flex: 1; }
103
+ & .head-row form { display: inline; }
104
+ & .status-toggle-form { flex: 0 0 auto; }
105
+ & .status-toggle { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 176px; min-height: 36px; padding: 3px; border-radius: 999px; border: 1px solid var(--lvc-border); background: var(--lvc-bg); color: var(--lvc-muted); overflow: hidden; }
106
+ & .status-toggle::before { content: ""; position: absolute; z-index: 0; top: 3px; bottom: 3px; width: calc(50% - 3px); border-radius: 999px; background: var(--lvc-surface); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); transition: left .16s ease; }
107
+ & .status-toggle.is-open::before { left: 3px; }
108
+ & .status-toggle.is-resolved::before { left: 50%; }
109
+ & .status-toggle span { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; min-width: 0; padding: 0 10px; font-size: 13px; font-weight: 700; white-space: nowrap; }
110
+ & .status-toggle.is-open span:first-child { color: var(--lvc-open); }
111
+ & .status-toggle.is-resolved span:last-child { color: var(--lvc-text); }
112
+ & .status-toggle:hover { text-decoration: none; color: var(--lvc-text); }
113
+ & button,
114
+ & .button { padding: 8px 14px; border: 1px solid var(--lvc-border); border-radius: 8px; cursor: pointer; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; }
115
+ & button.primary { background: var(--lvc-accent); border-color: var(--lvc-accent); color: var(--lvc-accent-text); }
116
+ & .case-id { color: var(--lvc-muted); font-weight: 400; font-size: 15px; }
117
+ & .avatars { display: inline-flex; }
118
+ & .avatars .avatar { width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: #fff; border: 2px solid var(--lvc-surface); cursor: default; }
119
+ & .avatars .avatar + .avatar { margin-left: -8px; }
120
+ & .avatar.color-0 { background: #2563eb; }
121
+ & .avatar.color-1 { background: #16a34a; }
122
+ & .avatar.color-2 { background: #d97706; }
123
+ & .avatar.color-3 { background: #dc2626; }
124
+ & .avatar.color-4 { background: #7c3aed; }
125
+ & .avatar.color-5 { background: #0891b2; }
126
+ & .context-line { margin: -6px 0 16px; overflow-wrap: anywhere; }
127
+ & .thread { display: flex; flex-direction: column; gap: 4px; padding: 16px; }
128
+ & .thread .who { font-size: 12px; color: var(--lvc-muted); margin: 10px 4px 2px; }
129
+ & .thread .who.visitor { text-align: left; }
130
+ & .thread .who.agent { text-align: right; }
131
+ & .thread .msg { max-width: 78%; padding: 8px 12px; border-radius: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
132
+ & .thread .msg.visitor { align-self: flex-start; background: var(--lvc-bg); border: 1px solid var(--lvc-border); border-bottom-left-radius: 4px; }
133
+ & .thread .msg.agent { align-self: flex-end; background: var(--lvc-accent); color: var(--lvc-accent-text); border-bottom-right-radius: 4px; }
134
+ & .thread .system { align-self: center; color: var(--lvc-muted); font-size: 12px; margin: 8px 0; }
135
+ & .thread .typing { align-self: flex-start; color: var(--lvc-muted); font-size: 12px; font-style: italic; margin: 8px 4px 2px; }
136
+ & .thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
137
+ & .thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
138
+ & .thread .msg .att-audio { display: block; width: 280px; max-width: 100%; height: 36px; }
139
+ & .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(0,0,0,.06); color: inherit; font-size: 13px; overflow-wrap: anywhere; }
140
+ & .thread .msg.agent .att-file { background: rgba(255,255,255,.2); }
141
+ & #new-messages { margin: 12px 0 0; }
142
+ & #new-messages a { font-weight: 600; }
143
+ & /* Unified composer box (matches the visitor widget): the reply on top,
144
+ & a
145
+ toolbar row below (tools left,
146
+ & send right),
147
+ & all inside one bordered box
148
+ that lights up on focus. */
149
+ .reply { display: flex; flex-direction: column; gap: 6px; margin: 10px 12px; padding: 8px 10px; border: 1px solid var(--lvc-border); border-radius: 14px; background: var(--lvc-bg); }
150
+ & .reply:focus-within { border-color: var(--lvc-accent); }
151
+ & .reply textarea { -webkit-appearance: none; appearance: none; border: none; background: none; resize: none; outline: none; font: inherit; color: var(--lvc-text); padding: 4px 4px 0; min-height: 40px; max-height: 160px; overflow-y: auto; }
152
+ & .reply input[type=file].reply-file { font-size: 12px; color: var(--lvc-muted); }
153
+ & .reply-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
154
+ & .reply-tools { display: flex; align-items: center; gap: 2px; }
155
+ & .reply .attach { width: 32px; height: 32px; padding: 0; border: none; background: none; color: var(--lvc-muted); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
156
+ & .reply .attach:hover { background: var(--lvc-border); color: var(--lvc-text); }
157
+ & .reply .attach.recording-on { color: var(--lvc-danger); }
158
+ & .reply.is-recording .attach { display: none; }
159
+ & .reply .chips { display: flex; flex-wrap: wrap; gap: 6px; }
160
+ & .reply .chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 6px 4px 10px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); font-size: 12px; }
161
+ & .reply .chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
162
+ & .reply .chip-x { padding: 0 2px; border: none; background: none; color: var(--lvc-muted); font-size: 16px; line-height: 1; cursor: pointer; }
163
+ & .reply .chip-x:hover { color: var(--lvc-text); }
164
+ & .reply .recording { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 4px 0 8px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); color: var(--lvc-muted); font-size: 12px; white-space: nowrap; }
165
+ & .reply .recording[hidden] { display: none; }
166
+ & .reply .recording-status { display: inline-grid; grid-template-columns: 8px 24px 34px; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
167
+ & .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lvc-danger); box-shadow: 0 0 0 3px rgba(220,38,38,.12); }
168
+ & .reply .recording-rec { font-size: 10px; font-weight: 700; letter-spacing: .04em; color: var(--lvc-danger); }
169
+ & .reply .recording-actions { display: flex; align-items: center; gap: 4px; }
170
+ & .reply .recording-actions button { width: 24px; height: 24px; padding: 0; border: none; border-radius: 50%; background: none; color: var(--lvc-muted); display: flex; align-items: center; justify-content: center; }
171
+ & .reply .recording-actions button:hover { background: var(--lvc-border); color: var(--lvc-text); }
172
+ & .reply .recording-actions .stop-recording { background: var(--lvc-danger); color: #fff; }
173
+ & .reply .recording-actions .stop-recording:hover { background: #b91c1c; color: #fff; }
174
+ & .reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; background: var(--lvc-accent); color: var(--lvc-accent-text); cursor: pointer; display: flex; align-items: center; justify-content: center; }
175
+ & /* Desktop inbox: a durable conversation list on the left and the active
176
+ thread on the right,
177
+ & with each pane owning its own scroll. */
178
+ .lvc-inbox,
179
+ & .lvc-inbox .container { height: 100vh; height: 100dvh; }
180
+ & .inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr; gap: 16px; }
181
+ & .inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--lvc-surface); border: 1px solid var(--lvc-border); border-radius: 8px; overflow: hidden; }
182
+ & .inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent; border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
183
+ & .inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px; border-radius: 8px; color: var(--lvc-muted); font-weight: 600; }
184
+ & .inbox-sidebar .tabs a.active { color: var(--lvc-text); border: 0; background: transparent; box-shadow: none; margin: 0; }
185
+ & .inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0; height: 2px; border-radius: 999px; background: var(--lvc-accent); }
186
+ & .inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--lvc-text); background: var(--lvc-bg); }
187
+ & .inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--lvc-muted) 14%, transparent); }
188
+ & .inbox-sidebar .tabs a.active .count { background: var(--lvc-border); }
189
+ & .inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--lvc-border); }
190
+ & .inbox-sidebar .filters input[type=search] { max-width: none; }
191
+ & .conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
192
+ & .conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; color: var(--lvc-text); border-bottom: 1px solid var(--lvc-border); }
193
+ & .conversation-row:hover { text-decoration: none; background: var(--lvc-bg); }
194
+ & .conversation-row.active { background: color-mix(in srgb, var(--lvc-accent) 9%, var(--lvc-surface)); box-shadow: inset 3px 0 0 var(--lvc-accent); }
195
+ & .conversation-row.unread .conversation-name,
196
+ & .conversation-row.unread .conversation-preview { font-weight: 700; }
197
+ & .conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
198
+ & .conversation-topline,
199
+ & .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
200
+ & .conversation-name,
201
+ & .conversation-preview,
202
+ & .conversation-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
203
+ & .conversation-name { font-weight: 600; }
204
+ & .conversation-time { margin-left: auto; white-space: nowrap; }
205
+ & .conversation-preview { display: block; }
206
+ & .conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
207
+ & .conversation-side .avatars { min-height: 26px; }
208
+ & .inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--lvc-border); }
209
+ & .inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
210
+ & .inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 0; }
211
+ & .conversation-panel .head-row,
212
+ & .conversation-panel .context-line { flex: 0 0 auto; }
213
+ & .conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-radius: 8px; }
214
+ & .conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
215
+ & .conversation-panel .reply { flex: 0 0 auto; }
216
+ & .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
217
+ & .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
218
+ & .empty-state p { margin: 0; }
219
+ & .mobile-back { display: none; }
220
+ & /* Conversation page: fill the viewport and scroll the THREAD,
221
+ & not the
222
+ page — so a reply never jumps the page,
223
+ & and the composer stays put. */
224
+ .lvc-convo,
225
+ & .lvc-convo .container { height: 100vh; height: 100dvh; }
226
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.7.2'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/livechat.rb CHANGED
@@ -29,6 +29,13 @@ module Livechat
29
29
  end
30
30
 
31
31
  # Can this request work the inbox? Checked by every inbox action.
32
+ # The class Livechat::DashboardController inherits from. Resolved on every
33
+ # call rather than memoized, so a host that reassigns base_controller_class
34
+ # in a reloadable initializer is not pinned to a stale, unloaded constant.
35
+ def base_controller
36
+ config.base_controller_class.to_s.constantize
37
+ end
38
+
32
39
  def agent?(request)
33
40
  !!config.authorize_agent.call(request)
34
41
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: livechat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -43,9 +43,11 @@ files:
43
43
  - MIT-LICENSE
44
44
  - README.md
45
45
  - Rakefile
46
+ - app/controllers/concerns/livechat/request_context.rb
46
47
  - app/controllers/livechat/application_controller.rb
47
48
  - app/controllers/livechat/attachments_controller.rb
48
49
  - app/controllers/livechat/conversations_controller.rb
50
+ - app/controllers/livechat/dashboard_controller.rb
49
51
  - app/controllers/livechat/messages_controller.rb
50
52
  - app/controllers/livechat/visitor_controller.rb
51
53
  - app/controllers/livechat/widgets_controller.rb
@@ -61,6 +63,7 @@ files:
61
63
  - app/views/livechat/conversations/show.html.erb
62
64
  - app/views/livechat/mailer/new_agent_reply.text.erb
63
65
  - app/views/livechat/mailer/new_visitor_message.text.erb
66
+ - app/views/livechat/shared/_dashboard.html.erb
64
67
  - config/locales/livechat.ar.yml
65
68
  - config/locales/livechat.bg.yml
66
69
  - config/locales/livechat.bn.yml
@@ -91,6 +94,7 @@ files:
91
94
  - lib/generators/livechat/install/install_generator.rb
92
95
  - lib/generators/livechat/install/templates/create_livechat_tables.rb.tt
93
96
  - lib/generators/livechat/install/templates/initializer.rb
97
+ - lib/generators/livechat/migration_helpers.rb
94
98
  - lib/livechat.rb
95
99
  - lib/livechat/channels.rb
96
100
  - lib/livechat/configuration.rb