livechat 0.4.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f0dd913b9f9ac88756a298d2ad57cf69c78d3f8bf31c08941171a6d13626a16
4
- data.tar.gz: 1148803aaf1b0d465afd5526ee6196d0814f23f0a3c75d4f9a7cfbd3ba96f6ee
3
+ metadata.gz: c43b02b890218a998865cc1e5e48d03aa292a120cac3a07045bf18e52f92f33b
4
+ data.tar.gz: eb8be46400159aef252e0d2bcd2ffed67a086f9c98cc7b4e2d055543e52e657c
5
5
  SHA512:
6
- metadata.gz: 8eff7b6fff9df34b825dd647d41eabc4e415572bd5dd282465f52de3e2ac4fa3ee648a6123b426dc67615f064d82e3354762b0cc9103ea9d724d251de5e769c8
7
- data.tar.gz: 57905952ca50aaa60b2103577ab54f6ee433f0729329ee5ea999e7a4bade5100a9e585ce9702842857962209f3b63369db9dde0b03358a723d4a9bb960e48251
6
+ metadata.gz: a73f3a61db23291760581906d03b2886dff33ff58a68eeceb5f52ce1826deff66e2c0867d3ac4d058824ba297aa923515e9c42a5675d8a9b5705977b945223b6
7
+ data.tar.gz: 81b7e4f24815ccd1c549f7fbc885ed108a800e3906e7d168f91c4a33a4a269dd596ee14ac2066d6a98e0f34e13c10caba60fb0702b0569a3f89540466173db11
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## 0.4.8
4
20
 
5
21
  - A conversation with image attachments now opens scrolled to the bottom, not
data/README.md CHANGED
@@ -1,64 +1,17 @@
1
1
  # livechat
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/livechat)](https://rubygems.org/gems/livechat)
4
+ [![Downloads](https://img.shields.io/gem/dt/livechat)](https://rubygems.org/gems/livechat)
4
5
  [![CI](https://github.com/yshmarov/livechat/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
6
7
 
7
- Open-source live chat for Rails. Alternative to Crisp, Intercom and
8
- Chatwoot — inside your Rails app, as a gem, not another service to deploy.
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
- ## How it works
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
+ ![The livechat widget open over a running Rails app](docs/screenshots/01-widget.png)
48
13
 
49
- Realtime is polling, on purpose: ~4s while the panel is open, ~30s in the
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
- ## Configuration
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
- Everything lives in `config/initializers/livechat.rb`; every option has a
88
- working default. The essentials:
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
+ | ![The inbox: open and resolved tabs, search, and a table of conversations](docs/screenshots/03-inbox.png) | ![A conversation: signed replies, timestamps, resolve event and composer](docs/screenshots/04-conversation.png) |
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 = ->(request) { request.env["warden"]&.user }
93
- config.authorize_agent = ->(request) { request.env["warden"]&.user&.admin? }
94
- config.mailer_from = "chat@example.com"
95
- config.agent_emails = -> { User.where(admin: true).pluck(:email) }
96
- config.reply_time_text = "We usually reply within an hour."
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
- `current_user` (and any admin gate) receives the raw request, so it works
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
- ### Brand color
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
- ### Email, both directions
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
- per unread stretch, not one per message. When an agent replies and the
137
- visitor is away, the visitor gets one email (signed-in visitors
138
- automatically, guests once they leave an address — the widget asks, gently).
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
- For anything else, hook in:
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
- ### File attachments
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
- reply form. Images render inline in the thread, other files as download
158
- links. Every file is served through the engine's own route and gated the
159
- same way the chat is — an agent, or the visitor who owns that conversation —
160
- so nothing leaks through a guessable or long-lived blob URL.
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
- ### Realtime with Action Cable (optional)
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
- stays the fallback, so a dropped socket or a proxy that blocks WebSockets
183
- never means a missed message. The widget speaks the Action Cable protocol
184
- over a plain WebSocket — no `@rails/actioncable`, no build step — and only
185
- ever subscribes to a stream the server signed for it. Under a strict CSP,
186
- allow the socket with `connect-src 'self'`.
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
- Browse at the mount path: open and resolved tabs, unread badges, search
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
- - `window.Livechat.open()` / `window.Livechat.close()` —
200
- `open("Hi, I need help with…")` prefills the message box (never over a
201
- visitor's draft)
202
- - Any element with `data-livechat-open` opens the panel on click; add
203
- `data-livechat-message="…"` to prefill — great for contextual buttons
204
- ("Request verification", "Ask about billing")
205
- - While replies are unread, every `data-livechat-open` element carries a
206
- small count badge — so hiding the launcher doesn't hide the answer
207
- - `<%= livechat_button %>` renders a plain, unstyled opener button
208
- - `config.show_launcher = false` hides the bubble entirely bring your own
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
- your users to be able to reach you from inside your Rails app — this is a
217
- gem's worth of exactly that.
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
- ## Testing
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.4.8'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -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() {
@@ -953,7 +998,9 @@
953
998
  "{left:0;right:0;top:0;bottom:0;width:100%;max-width:100%;height:100dvh;max-height:100dvh;" +
954
999
  "border-radius:0;border:none}" +
955
1000
  // The panel is already full-screen here, so there's nothing to expand.
956
- "#lvc-expand{display:none}" +
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}" +
957
1004
  // 16px stops iOS Safari from zoom-jumping into focused fields.
958
1005
  "#lvc-root #lvc-input,#lvc-root #lvc-email input{font-size:16px}" +
959
1006
  // Keep the composer box above the iPhone home indicator.
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.4.8
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov