livechat 0.4.7 → 0.5.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/CHANGELOG.md +24 -0
- data/README.md +170 -131
- data/lib/livechat/dashboard.js +16 -1
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +51 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c43b02b890218a998865cc1e5e48d03aa292a120cac3a07045bf18e52f92f33b
|
|
4
|
+
data.tar.gz: eb8be46400159aef252e0d2bcd2ffed67a086f9c98cc7b4e2d055543e52e657c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a73f3a61db23291760581906d03b2886dff33ff58a68eeceb5f52ce1826deff66e2c0867d3ac4d058824ba297aa923515e9c42a5675d8a9b5705977b945223b6
|
|
7
|
+
data.tar.gz: 81b7e4f24815ccd1c549f7fbc885ed108a800e3906e7d168f91c4a33a4a269dd596ee14ac2066d6a98e0f34e13c10caba60fb0702b0569a3f89540466173db11
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- The full-screen mobile panel now survives the on-screen keyboard. On phones
|
|
6
|
+
the panel is a fixed `100dvh` box, and iOS Safari draws the keyboard over it
|
|
7
|
+
without resizing it — so the composer and send button ended up hidden behind
|
|
8
|
+
the keyboard with a big empty gap above. The widget now pins the open panel
|
|
9
|
+
to the `visualViewport` (the area left visible above the keyboard), shrinking
|
|
10
|
+
its height and following the viewport offset so the composer sits just above
|
|
11
|
+
the keyboard and the newest message stays in view. Desktop popover behaviour
|
|
12
|
+
is unchanged (the pinning only runs while the mobile full-screen media query
|
|
13
|
+
matches), and browsers without the `visualViewport` API simply keep the old
|
|
14
|
+
CSS behaviour.
|
|
15
|
+
- The desktop-only expand/collapse control is now actually hidden on phones,
|
|
16
|
+
where the panel is already full-screen. (The mobile rule existed but lost on
|
|
17
|
+
specificity to the base button style, so the button was still showing.)
|
|
18
|
+
|
|
19
|
+
## 0.4.8
|
|
20
|
+
|
|
21
|
+
- A conversation with image attachments now opens scrolled to the bottom, not
|
|
22
|
+
mid-thread. Images load after the initial scroll and grow the thread, so both
|
|
23
|
+
the inbox and the widget now re-pin to the bottom as each image loads (and
|
|
24
|
+
the inbox keeps you pinned until you scroll up). Fixes threads with
|
|
25
|
+
screenshots opening somewhere in the middle.
|
|
26
|
+
|
|
3
27
|
## 0.4.7
|
|
4
28
|
|
|
5
29
|
- The inbox reply composer now matches the visitor widget: one bordered box
|
data/README.md
CHANGED
|
@@ -1,64 +1,17 @@
|
|
|
1
1
|
# livechat
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/livechat)
|
|
4
|
+
[](https://rubygems.org/gems/livechat)
|
|
4
5
|
[](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
|
|
5
6
|
[](MIT-LICENSE)
|
|
6
7
|
|
|
7
|
-
Open-source live chat for Rails
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Your users already have questions. `livechat` gives them a chat bubble and
|
|
11
|
-
gives you an inbox, inside the app you already run. Visitors write — signed
|
|
12
|
-
in or not. Your team answers — together, every reply signed with its author.
|
|
13
|
-
Nobody is around? Visitors leave an email and the conversation continues
|
|
14
|
-
there. Every word stays in your database.
|
|
15
|
-
|
|
16
|
-
- **A gem, not a platform.** `bundle add livechat`, one migration, one line
|
|
17
|
-
in your layout. No second app to deploy, no third-party script on your
|
|
18
|
-
pages, no per-seat pricing. Ever.
|
|
19
|
-
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
|
|
20
|
-
No Tailwind, no Stimulus, no importmap, no build step, no websockets to
|
|
21
|
-
configure. Works with Turbo Drive and strict nonce-based CSPs out of the box.
|
|
22
|
-
- **Honest about response time.** The widget says "We usually reply within a
|
|
23
|
-
few hours" (you choose the words), not a fake "we're online". Email
|
|
24
|
-
notifications — both directions — keep slow conversations alive.
|
|
25
|
-
- **A team sport.** Any authorized teammate answers any thread; each message
|
|
26
|
-
carries its author. You decide what visitors see — full names, first
|
|
27
|
-
names, or an anonymous "Support team".
|
|
28
|
-
- **Real attribution.** Signed-in visitors are identified server-side against
|
|
29
|
-
your user records and keep their thread across devices. Guests get a
|
|
30
|
-
cookie, and keep their history when they sign up.
|
|
31
|
-
- **Send files, both ways.** Visitors and agents attach images and documents;
|
|
32
|
-
images show inline. Files are served through the engine — gated exactly like
|
|
33
|
-
the chat — never a public blob URL. Needs Active Storage; degrades to
|
|
34
|
-
text-only where it's absent.
|
|
35
|
-
- **26 languages.** The widget follows your app's locale, RTL included.
|
|
8
|
+
**Open-source live chat for Rails.** A chat bubble for your users, an inbox for
|
|
9
|
+
your team — as a gem, not another service to deploy. Alternative to Crisp,
|
|
10
|
+
Intercom and Chatwoot.
|
|
36
11
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
1. Add `<%= livechat_tag %>` to your layout. A chat bubble appears
|
|
40
|
-
bottom-right (or open the panel from any element with
|
|
41
|
-
`data-livechat-open`, or `window.Livechat.open()`).
|
|
42
|
-
2. A visitor writes. The message lands in `livechat_conversations` in your
|
|
43
|
-
database, and — if you configured it — in your team's email.
|
|
44
|
-
3. Your team answers at the mount path (`/livechat`). Several people can
|
|
45
|
-
work the same thread; resolve it when done. A visitor writing again
|
|
46
|
-
reopens it — one thread per visitor, like a conversation, not tickets.
|
|
47
|
-
4. The visitor sees the reply in the widget, or by email when they're gone.
|
|
12
|
+

|
|
48
13
|
|
|
49
|
-
|
|
50
|
-
background, nothing at all for visitors who never wrote. No Action Cable,
|
|
51
|
-
no Redis, no infrastructure. At support-chat volume you will not notice;
|
|
52
|
-
your ops person will notice there is nothing new to run. If you already run
|
|
53
|
-
Action Cable and want replies to land the instant they're sent, turn it on
|
|
54
|
-
(`config.action_cable = true`) — polling stays the fallback.
|
|
55
|
-
|
|
56
|
-
## Requirements
|
|
57
|
-
|
|
58
|
-
- Ruby >= 3.2
|
|
59
|
-
- Rails >= 7.1
|
|
60
|
-
|
|
61
|
-
## Installation
|
|
14
|
+
## Install
|
|
62
15
|
|
|
63
16
|
```ruby
|
|
64
17
|
# Gemfile
|
|
@@ -71,57 +24,140 @@ bin/rails generate livechat:install
|
|
|
71
24
|
bin/rails db:migrate
|
|
72
25
|
```
|
|
73
26
|
|
|
74
|
-
The generator writes `config/initializers/livechat.rb`, creates the
|
|
75
|
-
migration, and mounts the engine at `/livechat`. Then add the widget to your
|
|
76
|
-
layout:
|
|
77
|
-
|
|
78
27
|
```erb
|
|
28
|
+
<%# app/views/layouts/application.html.erb %>
|
|
79
29
|
<%= livechat_tag %>
|
|
80
30
|
```
|
|
81
31
|
|
|
82
32
|
That's it. Visit any page, click the bubble, say hi. Answer yourself at
|
|
83
33
|
`/livechat`.
|
|
84
34
|
|
|
85
|
-
|
|
35
|
+
> [!IMPORTANT]
|
|
36
|
+
> The inbox defaults to **development only**. Set `authorize_agent` before you
|
|
37
|
+
> deploy — see [Configure](#configure).
|
|
38
|
+
|
|
39
|
+
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want file attachments.
|
|
40
|
+
|
|
41
|
+
## What you get
|
|
42
|
+
|
|
43
|
+
| | |
|
|
44
|
+
| ------------- | ------------------------------------------------------------------------ |
|
|
45
|
+
| **Widget** | Chat bubble, expandable panel, drafts preserved, unread badges |
|
|
46
|
+
| **Inbox** | Open / resolved tabs, search across everything, who-worked-what column |
|
|
47
|
+
| **Team** | Any teammate answers any thread; every reply signed with its author |
|
|
48
|
+
| **Email** | Both directions — one per unread stretch, not one per message |
|
|
49
|
+
| **Files** | Images inline, documents as links. Served through the engine, never a blob URL |
|
|
50
|
+
| **Realtime** | Polling by default (no Redis, no Action Cable). Push is opt-in |
|
|
51
|
+
| **Threads** | One per visitor — a conversation, not tickets. Writing again reopens it |
|
|
52
|
+
| **Deps** | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
|
|
53
|
+
| **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, anything |
|
|
54
|
+
| **i18n** | 26 languages, RTL included |
|
|
55
|
+
| **Turbo/CSP** | Turbo Drive and strict nonce-based CSP out of the box |
|
|
56
|
+
|
|
57
|
+
## Why a gem
|
|
58
|
+
|
|
59
|
+
| | `livechat` | Hosted chat SaaS |
|
|
60
|
+
| ---------------------- | ------------------------------ | --------------------------- |
|
|
61
|
+
| Cost | Free, MIT | Per-seat, per-month |
|
|
62
|
+
| Where conversations live | Your database | The vendor's |
|
|
63
|
+
| To deploy | `bundle add livechat` | A script tag, or a second app |
|
|
64
|
+
| Visitor identity | Server-side, from your session | Whatever the visitor types |
|
|
65
|
+
| Page weight | One `<script>`, no CDN | Third-party bundle |
|
|
66
|
+
| Infrastructure | None. Polling by default | Theirs, or your own Redis + Cable |
|
|
67
|
+
| "Powered by" badge | Never | Usually, until you pay |
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
1. Add `<%= livechat_tag %>` to your layout. A bubble appears bottom-right —
|
|
72
|
+
or open the panel from any element with `data-livechat-open`, or
|
|
73
|
+
`window.Livechat.open()`.
|
|
74
|
+
2. A visitor writes. The message lands in `livechat_conversations` in your
|
|
75
|
+
database, and — if you configured it — in your team's email.
|
|
76
|
+
3. Your team answers at `/livechat`. Several people can work the same thread;
|
|
77
|
+
resolve it when done. A visitor writing again reopens it.
|
|
78
|
+
4. The visitor sees the reply in the widget, or by email when they're gone.
|
|
79
|
+
|
|
80
|
+
**Realtime is polling, on purpose:** ~4s while the panel is open, ~30s in the
|
|
81
|
+
background, nothing at all for visitors who never wrote. No Action Cable, no
|
|
82
|
+
Redis, no infrastructure. At support-chat volume you will not notice; your ops
|
|
83
|
+
person will notice there is nothing new to run. Already running Action Cable
|
|
84
|
+
and want instant delivery? `config.action_cable = true` — polling stays the
|
|
85
|
+
fallback.
|
|
86
|
+
|
|
87
|
+
## The inbox
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
Open and resolved tabs, unread badges, search (visitor name, email, and
|
|
90
|
+
everything anyone wrote), and a column showing which teammates have worked each
|
|
91
|
+
thread. Reply with Cmd/Ctrl+Enter, resolve, reopen. Both pages keep themselves
|
|
92
|
+
fresh while you watch — and never reload over a half-written reply or search.
|
|
93
|
+
|
|
94
|
+
| The list | The thread |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
|  |  |
|
|
97
|
+
|
|
98
|
+
Every reply carries its author and time, and resolving is recorded in the
|
|
99
|
+
thread. Gated by `authorize_agent`.
|
|
100
|
+
|
|
101
|
+
## Configure
|
|
102
|
+
|
|
103
|
+
Everything is optional — a fresh install works with zero config. In
|
|
104
|
+
`config/initializers/livechat.rb`:
|
|
105
|
+
|
|
106
|
+
| Option | Default | What it does |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `authorize_agent` | development only | **Who can read the inbox.** Override before deploying |
|
|
109
|
+
| `enabled` | everyone | Who sees the widget. `false` hides it and rejects posts |
|
|
110
|
+
| `current_user` | `nil` | Identify the visitor. Receives the request |
|
|
111
|
+
| `app_name` | Rails app name | Shown in the widget header |
|
|
112
|
+
| `greeting` | localized default | First message visitors see |
|
|
113
|
+
| `reply_time_text` | localized default | "We usually reply within a few hours" |
|
|
114
|
+
| `launcher_label` | localized default | Text on the bubble |
|
|
115
|
+
| `accent_color` | `nil` | One hex restyles launcher, header, bubbles, send button |
|
|
116
|
+
| `show_launcher` | `true` | `false` hides the bubble — bring your own entry point |
|
|
117
|
+
| `visitor_label` | name, else email | How a visitor is labelled in the inbox |
|
|
118
|
+
| `agent_label` | name, else email | How an agent is labelled internally |
|
|
119
|
+
| `agent_display_name` | the full label | What **visitors** see — trim it or anonymise it |
|
|
120
|
+
| `mailer_from` | `nil` | Required for any email |
|
|
121
|
+
| `agent_emails` | `nil` | Who gets notified of new visitor messages |
|
|
122
|
+
| `on_visitor_message` | no-op | Runs after a visitor writes — Slack, etc. |
|
|
123
|
+
| `on_agent_message` | no-op | Runs after an agent replies |
|
|
124
|
+
| `attach_files` | `true` | File attachments (needs Active Storage) |
|
|
125
|
+
| `max_attachments` | `5` | Per message |
|
|
126
|
+
| `max_attachment_size` | `10.megabytes` | Enforced server-side |
|
|
127
|
+
| `allowed_attachment_types` | `nil` (any) | Or an allowlist, e.g. `%w[image/png application/pdf]` |
|
|
128
|
+
| `action_cable` | `false` | Opt into push delivery |
|
|
129
|
+
| `action_cable_url` | `"/cable"` | Match your `mount ActionCable...` |
|
|
130
|
+
| `rate_limit` | `{ to: 30, within: 1.minute }` | Per-IP throttle (Rails 7.2+). `nil` disables |
|
|
131
|
+
| `mount_path` | `"/livechat"` | Keep in sync with `mount` in `routes.rb` |
|
|
132
|
+
|
|
133
|
+
A typical initializer:
|
|
89
134
|
|
|
90
135
|
```ruby
|
|
91
136
|
Livechat.configure do |config|
|
|
92
|
-
config.current_user
|
|
93
|
-
config.authorize_agent
|
|
94
|
-
config.mailer_from
|
|
95
|
-
config.agent_emails
|
|
96
|
-
config.reply_time_text
|
|
137
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
138
|
+
config.authorize_agent = ->(request) { request.env["warden"]&.user&.admin? }
|
|
139
|
+
config.mailer_from = "chat@example.com"
|
|
140
|
+
config.agent_emails = -> { User.where(admin: true).pluck(:email) }
|
|
141
|
+
config.reply_time_text = "We usually reply within an hour."
|
|
142
|
+
config.accent_color = "#7c3aed"
|
|
97
143
|
end
|
|
98
144
|
```
|
|
99
145
|
|
|
100
|
-
|
|
101
|
-
with whatever auth you have:
|
|
146
|
+
Gates receive the **raw request**, so they work with any auth:
|
|
102
147
|
|
|
103
148
|
```ruby
|
|
104
|
-
# Devise / Warden
|
|
149
|
+
# Devise / Warden
|
|
105
150
|
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
106
151
|
|
|
107
|
-
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
152
|
+
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
108
153
|
config.current_user = lambda do |request|
|
|
109
154
|
token = request.cookies["session_token"]
|
|
110
155
|
Session.find_signed(token)&.user if token
|
|
111
156
|
end
|
|
112
157
|
```
|
|
113
158
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
```ruby
|
|
117
|
-
config.accent_color = "#7c3aed"
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
One hex value restyles the launcher, header, visitor bubbles and send
|
|
121
|
-
button; the widget picks black or white text automatically for contrast,
|
|
122
|
-
in light and dark mode alike.
|
|
123
|
-
|
|
124
|
-
### Who visitors talk to
|
|
159
|
+
<details>
|
|
160
|
+
<summary><b>Who visitors talk to</b></summary>
|
|
125
161
|
|
|
126
162
|
Replies are signed. What visitors see is up to you:
|
|
127
163
|
|
|
@@ -130,22 +166,27 @@ config.agent_display_name = ->(label) { label.split.first } # "Ada"
|
|
|
130
166
|
config.agent_display_name = ->(_label) { "Support team" } # anonymous
|
|
131
167
|
```
|
|
132
168
|
|
|
133
|
-
|
|
169
|
+
</details>
|
|
170
|
+
|
|
171
|
+
<details>
|
|
172
|
+
<summary><b>Email, both directions</b></summary>
|
|
134
173
|
|
|
135
|
-
When a visitor writes and nobody has read it, the team gets one email — one
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Requires `config.mailer_from`; team notifications also need
|
|
140
|
-
`config.agent_emails`.
|
|
174
|
+
When a visitor writes and nobody has read it, the team gets one email — one per
|
|
175
|
+
unread stretch, not one per message. When an agent replies and the visitor is
|
|
176
|
+
away, the visitor gets one email (signed-in visitors automatically, guests once
|
|
177
|
+
they leave an address — the widget asks, gently).
|
|
141
178
|
|
|
142
|
-
|
|
179
|
+
Requires `mailer_from`; team notifications also need `agent_emails`. For
|
|
180
|
+
anything else, hook in:
|
|
143
181
|
|
|
144
182
|
```ruby
|
|
145
183
|
config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
|
|
146
184
|
```
|
|
147
185
|
|
|
148
|
-
|
|
186
|
+
</details>
|
|
187
|
+
|
|
188
|
+
<details>
|
|
189
|
+
<summary><b>File attachments</b></summary>
|
|
149
190
|
|
|
150
191
|
On by default wherever the app has Active Storage. If you don't already:
|
|
151
192
|
|
|
@@ -153,22 +194,18 @@ On by default wherever the app has Active Storage. If you don't already:
|
|
|
153
194
|
bin/rails active_storage:install && bin/rails db:migrate
|
|
154
195
|
```
|
|
155
196
|
|
|
156
|
-
Visitors get a paperclip in the composer; agents get a file field on the
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
```ruby
|
|
163
|
-
config.attach_files = true # false turns it off even with Active Storage
|
|
164
|
-
config.max_attachments = 5 # per message
|
|
165
|
-
config.max_attachment_size = 10.megabytes
|
|
166
|
-
config.allowed_attachment_types = nil # or an allowlist, e.g. %w[image/png image/jpeg application/pdf]
|
|
167
|
-
```
|
|
197
|
+
Visitors get a paperclip in the composer; agents get a file field on the reply
|
|
198
|
+
form. Images render inline, other files as download links. Every file is served
|
|
199
|
+
through the engine's own route and gated the same way the chat is — an agent,
|
|
200
|
+
or the visitor who owns that conversation — so nothing leaks through a
|
|
201
|
+
guessable or long-lived blob URL.
|
|
168
202
|
|
|
169
203
|
Where Active Storage isn't installed, the widget quietly stays text-only.
|
|
170
204
|
|
|
171
|
-
|
|
205
|
+
</details>
|
|
206
|
+
|
|
207
|
+
<details>
|
|
208
|
+
<summary><b>Realtime with Action Cable</b></summary>
|
|
172
209
|
|
|
173
210
|
Polling is the default and needs nothing from your app. If you already run
|
|
174
211
|
Action Cable, turn on push so a reply appears the instant it's sent:
|
|
@@ -178,51 +215,53 @@ config.action_cable = true
|
|
|
178
215
|
config.action_cable_url = "/cable" # match your `mount ActionCable... => ...`
|
|
179
216
|
```
|
|
180
217
|
|
|
181
|
-
A new message nudges the widget and the inbox to refresh at once; polling
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
## The inbox
|
|
218
|
+
A new message nudges the widget and the inbox to refresh at once; polling stays
|
|
219
|
+
the fallback, so a dropped socket or a proxy that blocks WebSockets never means
|
|
220
|
+
a missed message. The widget speaks the Action Cable protocol over a plain
|
|
221
|
+
WebSocket — no `@rails/actioncable`, no build step — and only ever subscribes
|
|
222
|
+
to a stream the server signed for it. Under a strict CSP, allow the socket with
|
|
223
|
+
`connect-src 'self'`.
|
|
189
224
|
|
|
190
|
-
|
|
191
|
-
(visitor name, email, and everything anyone wrote), and a column showing
|
|
192
|
-
which teammates have worked each thread. One click into a thread; reply
|
|
193
|
-
(Cmd/Ctrl+Enter sends), resolve, reopen. Both pages keep themselves fresh
|
|
194
|
-
while you watch — and never reload over a half-written reply or search.
|
|
195
|
-
Gated by `config.authorize_agent` (development-only until you set it).
|
|
225
|
+
</details>
|
|
196
226
|
|
|
197
227
|
## Widget API
|
|
198
228
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
entry point
|
|
229
|
+
| | |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `window.Livechat.open()` / `.close()` | `open("Hi, I need help with…")` prefills the box — never over a visitor's draft |
|
|
232
|
+
| `data-livechat-open` | Any element opens the panel on click |
|
|
233
|
+
| `data-livechat-message="…"` | Prefill from that element — great for contextual buttons |
|
|
234
|
+
| `<%= livechat_button %>` | A plain, unstyled opener button |
|
|
235
|
+
| `config.show_launcher = false` | Hide the bubble entirely |
|
|
236
|
+
|
|
237
|
+
While replies are unread, every `data-livechat-open` element carries a small
|
|
238
|
+
count badge — so hiding the launcher never hides the answer.
|
|
210
239
|
|
|
211
240
|
## What it doesn't do
|
|
212
241
|
|
|
213
242
|
No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no
|
|
214
243
|
visitor tracking, no "powered by" badge. If you need a support platform,
|
|
215
|
-
[Chatwoot](https://github.com/chatwoot/chatwoot) is excellent. If you need
|
|
216
|
-
|
|
217
|
-
|
|
244
|
+
[Chatwoot](https://github.com/chatwoot/chatwoot) is excellent. If you need your
|
|
245
|
+
users to be able to reach you from inside your Rails app — this is a gem's
|
|
246
|
+
worth of exactly that.
|
|
218
247
|
|
|
219
|
-
##
|
|
248
|
+
## Development
|
|
220
249
|
|
|
221
250
|
```bash
|
|
222
251
|
bundle exec rake test
|
|
223
252
|
bundle exec rubocop
|
|
224
253
|
```
|
|
225
254
|
|
|
255
|
+
## Also by the same author
|
|
256
|
+
|
|
257
|
+
- [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
|
|
258
|
+
reviews and NPS for Rails.
|
|
259
|
+
- [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
|
|
260
|
+
feature requests.
|
|
261
|
+
- [i18n_proofreading](https://github.com/yshmarov/i18n_proofreading) — in-context
|
|
262
|
+
translation proofreading.
|
|
263
|
+
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
264
|
+
|
|
226
265
|
## License
|
|
227
266
|
|
|
228
267
|
MIT.
|
data/lib/livechat/dashboard.js
CHANGED
|
@@ -130,7 +130,21 @@
|
|
|
130
130
|
var thread = document.getElementById("thread");
|
|
131
131
|
if (!thread) return;
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// Stick to the bottom until the agent scrolls up. Images (screenshots and
|
|
134
|
+
// attachments) finish loading after DOMContentLoaded and grow the thread,
|
|
135
|
+
// so a single scroll-to-bottom now would land mid-thread once they expand
|
|
136
|
+
// — re-pin as each image loads and once the page has fully loaded.
|
|
137
|
+
var stick = true;
|
|
138
|
+
var keepBottom = function () { if (stick) thread.scrollTop = thread.scrollHeight; };
|
|
139
|
+
thread.addEventListener("scroll", function () {
|
|
140
|
+
stick = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
|
|
141
|
+
});
|
|
142
|
+
keepBottom();
|
|
143
|
+
var images = thread.querySelectorAll("img");
|
|
144
|
+
for (var i = 0; i < images.length; i++) {
|
|
145
|
+
if (!images[i].complete) images[i].addEventListener("load", keepBottom);
|
|
146
|
+
}
|
|
147
|
+
window.addEventListener("load", keepBottom);
|
|
134
148
|
|
|
135
149
|
var replyBox = document.querySelector(".reply textarea");
|
|
136
150
|
var latest = parseInt(thread.getAttribute("data-latest"), 10) || 0;
|
|
@@ -204,6 +218,7 @@
|
|
|
204
218
|
img.src = att.url;
|
|
205
219
|
img.alt = att.name;
|
|
206
220
|
img.loading = "lazy";
|
|
221
|
+
img.addEventListener("load", keepBottom); // a live image grows the thread
|
|
207
222
|
link.appendChild(img);
|
|
208
223
|
} else {
|
|
209
224
|
link.className = "att-file";
|
data/lib/livechat/version.rb
CHANGED
data/lib/livechat/widget.js
CHANGED
|
@@ -457,6 +457,7 @@
|
|
|
457
457
|
panel.hidden = false;
|
|
458
458
|
panel.setAttribute("aria-modal", isMobileModal() ? "true" : "false");
|
|
459
459
|
if (isMobileModal()) lockScroll();
|
|
460
|
+
bindViewport(); // pin the panel above the keyboard on phones
|
|
460
461
|
setUnread(0);
|
|
461
462
|
poll();
|
|
462
463
|
schedulePoll();
|
|
@@ -475,6 +476,7 @@
|
|
|
475
476
|
sessionSet("livechat_open", "");
|
|
476
477
|
var panel = document.getElementById("lvc-panel");
|
|
477
478
|
if (panel) panel.hidden = true;
|
|
479
|
+
unbindViewport();
|
|
478
480
|
unlockScroll();
|
|
479
481
|
if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
|
|
480
482
|
schedulePoll();
|
|
@@ -546,6 +548,49 @@
|
|
|
546
548
|
savedOverflow = null;
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
// --- mobile keyboard: keep the full-screen panel above the keyboard ---------
|
|
552
|
+
|
|
553
|
+
// On phones the panel is position:fixed at height:100dvh. When the composer
|
|
554
|
+
// is focused, iOS Safari draws the keyboard OVER that fixed box without
|
|
555
|
+
// resizing it — the send row ends up hidden behind the keyboard and the
|
|
556
|
+
// layout breaks. The visualViewport API reports the actually-visible
|
|
557
|
+
// rectangle (viewport minus keyboard), so we pin the panel to it: its height
|
|
558
|
+
// shrinks to the visible area and its top follows any viewport offset, which
|
|
559
|
+
// sits the composer just above the keyboard. Desktop never runs this — the
|
|
560
|
+
// work is gated on the mobile media query, and we clear the inline styles the
|
|
561
|
+
// moment it stops matching (e.g. a phone rotated to a wide viewport).
|
|
562
|
+
function syncViewport() {
|
|
563
|
+
var panel = document.getElementById("lvc-panel");
|
|
564
|
+
if (!panel || !window.visualViewport) return;
|
|
565
|
+
if (isMobileModal()) {
|
|
566
|
+
panel.style.height = window.visualViewport.height + "px";
|
|
567
|
+
panel.style.top = window.visualViewport.offsetTop + "px";
|
|
568
|
+
scrollToBottom(); // newest message stays in view above the keyboard
|
|
569
|
+
} else {
|
|
570
|
+
// Not the mobile modal — hand height/top back to the stylesheet.
|
|
571
|
+
panel.style.height = "";
|
|
572
|
+
panel.style.top = "";
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function bindViewport() {
|
|
577
|
+
if (!window.visualViewport) return; // older browsers: no-op, CSS stands
|
|
578
|
+
window.visualViewport.addEventListener("resize", syncViewport);
|
|
579
|
+
window.visualViewport.addEventListener("scroll", syncViewport);
|
|
580
|
+
syncViewport(); // apply once on open (covers a keyboard already up)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function unbindViewport() {
|
|
584
|
+
if (!window.visualViewport) return;
|
|
585
|
+
window.visualViewport.removeEventListener("resize", syncViewport);
|
|
586
|
+
window.visualViewport.removeEventListener("scroll", syncViewport);
|
|
587
|
+
var panel = document.getElementById("lvc-panel");
|
|
588
|
+
if (panel) {
|
|
589
|
+
panel.style.height = "";
|
|
590
|
+
panel.style.top = "";
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
549
594
|
// --- polling ----------------------------------------------------------------
|
|
550
595
|
|
|
551
596
|
function schedulePoll() {
|
|
@@ -681,6 +726,9 @@
|
|
|
681
726
|
img.src = att.url;
|
|
682
727
|
img.alt = att.name;
|
|
683
728
|
img.loading = "lazy";
|
|
729
|
+
// The image loads after its bubble is placed and grows the panel —
|
|
730
|
+
// re-pin to the bottom so a just-arrived image stays in view.
|
|
731
|
+
img.addEventListener("load", scrollToBottom);
|
|
684
732
|
link.appendChild(img);
|
|
685
733
|
} else {
|
|
686
734
|
link.className = "lvc-att-file";
|
|
@@ -950,7 +998,9 @@
|
|
|
950
998
|
"{left:0;right:0;top:0;bottom:0;width:100%;max-width:100%;height:100dvh;max-height:100dvh;" +
|
|
951
999
|
"border-radius:0;border:none}" +
|
|
952
1000
|
// The panel is already full-screen here, so there's nothing to expand.
|
|
953
|
-
|
|
1001
|
+
// Two ids to outrank the base `#lvc-root #lvc-expand{display:flex}` rule —
|
|
1002
|
+
// a bare `#lvc-expand` loses on specificity and never hides.
|
|
1003
|
+
"#lvc-root #lvc-expand{display:none}" +
|
|
954
1004
|
// 16px stops iOS Safari from zoom-jumping into focused fields.
|
|
955
1005
|
"#lvc-root #lvc-input,#lvc-root #lvc-email input{font-size:16px}" +
|
|
956
1006
|
// Keep the composer box above the iPhone home indicator.
|