livechat 0.1.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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +160 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/livechat/application_controller.rb +76 -0
  7. data/app/controllers/livechat/conversations_controller.rb +57 -0
  8. data/app/controllers/livechat/messages_controller.rb +21 -0
  9. data/app/controllers/livechat/visitor_controller.rb +108 -0
  10. data/app/controllers/livechat/widgets_controller.rb +36 -0
  11. data/app/helpers/livechat/widget_helper.rb +37 -0
  12. data/app/mailers/livechat/mailer.rb +51 -0
  13. data/app/models/livechat/application_record.rb +7 -0
  14. data/app/models/livechat/conversation.rb +108 -0
  15. data/app/models/livechat/message.rb +53 -0
  16. data/app/views/layouts/livechat/application.html.erb +97 -0
  17. data/app/views/livechat/conversations/index.html.erb +62 -0
  18. data/app/views/livechat/conversations/show.html.erb +73 -0
  19. data/app/views/livechat/mailer/new_agent_reply.text.erb +7 -0
  20. data/app/views/livechat/mailer/new_visitor_message.text.erb +7 -0
  21. data/config/locales/livechat.ar.yml +44 -0
  22. data/config/locales/livechat.bg.yml +44 -0
  23. data/config/locales/livechat.bn.yml +44 -0
  24. data/config/locales/livechat.de.yml +44 -0
  25. data/config/locales/livechat.el.yml +44 -0
  26. data/config/locales/livechat.en.yml +44 -0
  27. data/config/locales/livechat.es.yml +44 -0
  28. data/config/locales/livechat.fr.yml +44 -0
  29. data/config/locales/livechat.hi.yml +44 -0
  30. data/config/locales/livechat.hr.yml +44 -0
  31. data/config/locales/livechat.id.yml +44 -0
  32. data/config/locales/livechat.it.yml +44 -0
  33. data/config/locales/livechat.ja.yml +44 -0
  34. data/config/locales/livechat.ko.yml +44 -0
  35. data/config/locales/livechat.lb.yml +44 -0
  36. data/config/locales/livechat.nl.yml +44 -0
  37. data/config/locales/livechat.pl.yml +44 -0
  38. data/config/locales/livechat.pt.yml +44 -0
  39. data/config/locales/livechat.ro.yml +44 -0
  40. data/config/locales/livechat.ru.yml +44 -0
  41. data/config/locales/livechat.th.yml +44 -0
  42. data/config/locales/livechat.tr.yml +44 -0
  43. data/config/locales/livechat.uk.yml +44 -0
  44. data/config/locales/livechat.ur.yml +44 -0
  45. data/config/locales/livechat.vi.yml +44 -0
  46. data/config/locales/livechat.zh-CN.yml +44 -0
  47. data/config/routes.rb +27 -0
  48. data/lib/generators/livechat/install/install_generator.rb +42 -0
  49. data/lib/generators/livechat/install/templates/create_livechat_tables.rb.tt +36 -0
  50. data/lib/generators/livechat/install/templates/initializer.rb +59 -0
  51. data/lib/livechat/configuration.rb +103 -0
  52. data/lib/livechat/dashboard.js +58 -0
  53. data/lib/livechat/engine.rb +13 -0
  54. data/lib/livechat/notifications.rb +41 -0
  55. data/lib/livechat/version.rb +5 -0
  56. data/lib/livechat/widget.js +563 -0
  57. data/lib/livechat/widget.rb +115 -0
  58. data/lib/livechat.rb +56 -0
  59. metadata +125 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1f475396740f88ad5b62ea7f30889dd7e3b36699834f061d2a7d09cdf341b88a
4
+ data.tar.gz: cb58318b0d8ac30a316d59bca48e8eca3bbe63b697c0d744a2404820d9327344
5
+ SHA512:
6
+ metadata.gz: e261a7ad6751071cafb5546af26cc27fe2191ff7425c8aa001d52dc7ce996d0a7176cce69aecae2b7f2e09dccf38b6536dabc1c71af06f790f80b536c45de37a
7
+ data.tar.gz: 443fe470e3d66f164a4f970ea4d0e3fdfdd41e42d98d5a05674ac9783e97f4c128ea5dfe97866ad034e4a71eb3cc412e4ec8cabfaf3449c7a727e903925328c2
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: floating chat widget (guests + signed-in visitors, one
6
+ thread per visitor, email capture), team inbox with signed replies,
7
+ resolve/reopen with system messages, unread tracking on both sides,
8
+ polling transport (no Action Cable required), built-in notification
9
+ emails in both directions (one per unread stretch), `on_visitor_message` /
10
+ `on_agent_message` hooks, per-IP rate limiting, 26 languages, RTL, strict
11
+ CSP support.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Yaroslav Shmarov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # livechat
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/livechat)](https://rubygems.org/gems/livechat)
4
+ [![CI](https://github.com/yshmarov/livechat/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
6
+
7
+ Open-source live chat for Rails. Self-hosted alternative to Crisp, Intercom
8
+ and Chatwoot — 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
+ - **26 languages.** The widget follows your app's locale, RTL included.
32
+
33
+ ## How it works
34
+
35
+ 1. Add `<%= livechat_tag %>` to your layout. A chat bubble appears
36
+ bottom-right (or open the panel from any element with
37
+ `data-livechat-open`, or `window.Livechat.open()`).
38
+ 2. A visitor writes. The message lands in `livechat_conversations` in your
39
+ database, and — if you configured it — in your team's email.
40
+ 3. Your team answers at the mount path (`/livechat`). Several people can
41
+ work the same thread; resolve it when done. A visitor writing again
42
+ reopens it — one thread per visitor, like a conversation, not tickets.
43
+ 4. The visitor sees the reply in the widget, or by email when they're gone.
44
+
45
+ Realtime is polling, on purpose: ~4s while the panel is open, ~30s in the
46
+ background, nothing at all for visitors who never wrote. No Action Cable,
47
+ no Redis, no infrastructure. At support-chat volume you will not notice;
48
+ your ops person will notice there is nothing new to run.
49
+
50
+ ## Requirements
51
+
52
+ - Ruby >= 3.2
53
+ - Rails >= 7.1
54
+
55
+ ## Installation
56
+
57
+ ```ruby
58
+ # Gemfile
59
+ gem "livechat"
60
+ ```
61
+
62
+ ```bash
63
+ bundle install
64
+ bin/rails generate livechat:install
65
+ bin/rails db:migrate
66
+ ```
67
+
68
+ The generator writes `config/initializers/livechat.rb`, creates the
69
+ migration, and mounts the engine at `/livechat`. Then add the widget to your
70
+ layout:
71
+
72
+ ```erb
73
+ <%= livechat_tag %>
74
+ ```
75
+
76
+ That's it. Visit any page, click the bubble, say hi. Answer yourself at
77
+ `/livechat`.
78
+
79
+ ## Configuration
80
+
81
+ Everything lives in `config/initializers/livechat.rb`; every option has a
82
+ working default. The essentials:
83
+
84
+ ```ruby
85
+ Livechat.configure do |config|
86
+ config.current_user = ->(request) { request.env["warden"]&.user }
87
+ config.authorize_agent = ->(request) { request.env["warden"]&.user&.admin? }
88
+ config.mailer_from = "chat@example.com"
89
+ config.agent_emails = -> { User.where(admin: true).pluck(:email) }
90
+ config.reply_time_text = "We usually reply within an hour."
91
+ end
92
+ ```
93
+
94
+ ### Brand color
95
+
96
+ ```ruby
97
+ config.accent_color = "#7c3aed"
98
+ ```
99
+
100
+ One hex value restyles the launcher, header, visitor bubbles and send
101
+ button; the widget picks black or white text automatically for contrast,
102
+ in light and dark mode alike.
103
+
104
+ ### Who visitors talk to
105
+
106
+ Replies are signed. What visitors see is up to you:
107
+
108
+ ```ruby
109
+ config.agent_display_name = ->(label) { label.split.first } # "Ada"
110
+ config.agent_display_name = ->(_label) { "Support team" } # anonymous
111
+ ```
112
+
113
+ ### Email, both directions
114
+
115
+ When a visitor writes and nobody has read it, the team gets one email — one
116
+ per unread stretch, not one per message. When an agent replies and the
117
+ visitor is away, the visitor gets one email (signed-in visitors
118
+ automatically, guests once they leave an address — the widget asks, gently).
119
+ Requires `config.mailer_from`; team notifications also need
120
+ `config.agent_emails`.
121
+
122
+ For anything else, hook in:
123
+
124
+ ```ruby
125
+ config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
126
+ ```
127
+
128
+ ## The inbox
129
+
130
+ Browse at the mount path: open and resolved tabs, unread badges, one click
131
+ into a thread. Reply (Cmd/Ctrl+Enter sends), resolve, reopen. The thread
132
+ updates itself while you watch — and never reloads over a half-written
133
+ reply. Gated by `config.authorize_agent` (development-only until you set it).
134
+
135
+ ## Widget API
136
+
137
+ - `window.Livechat.open()` / `window.Livechat.close()`
138
+ - Any element with `data-livechat-open` opens the panel on click
139
+ - `<%= livechat_button %>` renders a plain, unstyled opener button
140
+ - `config.show_launcher = false` hides the bubble entirely — bring your own
141
+ entry point
142
+
143
+ ## What it doesn't do
144
+
145
+ No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no
146
+ visitor tracking, no "powered by" badge. If you need a support platform,
147
+ [Chatwoot](https://github.com/chatwoot/chatwoot) is excellent. If you need
148
+ your users to be able to reach you from inside your Rails app — this is a
149
+ gem's worth of exactly that.
150
+
151
+ ## Testing
152
+
153
+ ```bash
154
+ bundle exec rake test
155
+ bundle exec rubocop
156
+ ```
157
+
158
+ ## License
159
+
160
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.pattern = 'test/**/*_test.rb'
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ private
8
+
9
+ def current_visitor
10
+ return @current_visitor if defined?(@current_visitor)
11
+
12
+ @current_visitor = Livechat.config.current_user.call(request)
13
+ end
14
+
15
+ def current_visitor_id
16
+ current_visitor.respond_to?(:id) ? current_visitor.id.to_s : nil
17
+ end
18
+
19
+ def require_enabled
20
+ head :forbidden unless Livechat.enabled?(request)
21
+ end
22
+
23
+ # Server-side gate for the inbox. Default: development only.
24
+ def require_agent
25
+ return if Livechat.agent?(request)
26
+
27
+ render plain: 'Forbidden. Set Livechat.config.authorize_agent to grant access.',
28
+ status: :forbidden
29
+ end
30
+
31
+ # Replies are attributed to whoever config.current_user resolves. When an
32
+ # inbox is protected by something user-less (HTTP basic, a VPN), replies
33
+ # still work — anonymously, as "Support".
34
+ def current_agent_id
35
+ current_visitor_id || '0'
36
+ end
37
+
38
+ def current_agent_label
39
+ label = (Livechat.config.agent_label.call(current_visitor).presence if current_visitor)
40
+ label || I18n.t(:team, scope: :livechat, default: 'Support')
41
+ end
42
+
43
+ def render_rate_limited
44
+ message = I18n.t('livechat.error_rate_limited',
45
+ default: 'Too many messages. Please wait a moment and try again.')
46
+ render json: { errors: [message] }, status: :too_many_requests
47
+ end
48
+
49
+ # Guests get a permanent random token — their key to the conversation
50
+ # across visits. Signed-in visitors are keyed by id and only need the
51
+ # cookie to carry a guest history into their account.
52
+ def visitor_token
53
+ cookies[:livechat_vid]
54
+ end
55
+
56
+ def ensure_visitor_token
57
+ visitor_token.presence || begin
58
+ token = SecureRandom.base58(24)
59
+ cookies.permanent[:livechat_vid] = { value: token, httponly: true, same_site: :lax }
60
+ token
61
+ end
62
+ end
63
+
64
+ # A guest who signed in keeps their thread: adopt cookie-token
65
+ # conversations into the account, then the cookie no longer matters.
66
+ def claim_guest_conversations
67
+ return unless current_visitor_id && visitor_token
68
+
69
+ Conversation.claim!(
70
+ visitor_token: visitor_token,
71
+ visitor_id: current_visitor_id,
72
+ visitor_label: Livechat.config.visitor_label.call(current_visitor).presence
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # The inbox. Every action is gated by config.authorize_agent; any
5
+ # authorized teammate can read and answer any thread — it's a shared line,
6
+ # not an assignment queue.
7
+ class ConversationsController < ApplicationController
8
+ before_action :require_agent
9
+ before_action :set_conversation, except: :index
10
+ layout 'livechat/application'
11
+
12
+ PER_PAGE = 50
13
+
14
+ def index
15
+ @status = Conversation::STATUSES.include?(params[:status]) ? params[:status] : 'open'
16
+ @counts = Conversation.group(:status).count
17
+ @offset = params[:offset].to_i.clamp(0, 1_000_000)
18
+
19
+ scope = Conversation.where(status: @status).recent_first
20
+ page = scope.offset(@offset).limit(PER_PAGE + 1).to_a
21
+ @more = page.size > PER_PAGE
22
+ @conversations = page.first(PER_PAGE)
23
+
24
+ # Unread badges for the whole page in one query.
25
+ @unread = Message.from_visitor.unread
26
+ .where(conversation_id: @conversations.map(&:id))
27
+ .group(:conversation_id).count
28
+ end
29
+
30
+ def show
31
+ @messages = @conversation.messages.chronological.to_a
32
+ @conversation.mark_read_for_agent!
33
+ end
34
+
35
+ # Polled by dashboard.js: has anything new arrived in this thread?
36
+ def poll
37
+ render json: { latest: @conversation.messages.maximum(:id).to_i,
38
+ status: @conversation.status }
39
+ end
40
+
41
+ def resolve
42
+ @conversation.resolve_by!(current_agent_label)
43
+ redirect_to conversation_path(@conversation)
44
+ end
45
+
46
+ def reopen
47
+ @conversation.reopen_by!(current_agent_label)
48
+ redirect_to conversation_path(@conversation)
49
+ end
50
+
51
+ private
52
+
53
+ def set_conversation
54
+ @conversation = Conversation.find(params[:id])
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ class MessagesController < ApplicationController
5
+ before_action :require_agent
6
+
7
+ def create
8
+ conversation = Conversation.find(params[:conversation_id])
9
+ message = conversation.post_agent_message!(
10
+ body: params[:body].to_s.strip,
11
+ agent_id: current_agent_id,
12
+ agent_label: current_agent_label
13
+ )
14
+ Notifications.agent_message(message)
15
+
16
+ redirect_to conversation_path(conversation, anchor: "message-#{message.id}")
17
+ rescue ActiveRecord::RecordInvalid => e
18
+ redirect_to conversation_path(conversation), alert: e.record.errors.full_messages.first
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # The widget's API. Every action is scoped to the requesting visitor — the
5
+ # signed-in user's id, or the guest cookie token — so there is nothing to
6
+ # enumerate and no id to leak. Gated by config.enabled and rate-limited.
7
+ class VisitorController < ApplicationController
8
+ before_action :require_enabled
9
+ before_action :claim_guest_conversations
10
+
11
+ if respond_to?(:rate_limit) && Livechat.config.rate_limit
12
+ rate_limit(**Livechat.config.rate_limit,
13
+ only: %i[create email],
14
+ with: -> { render_rate_limited })
15
+ end
16
+
17
+ # GET widget/conversation(?after=<id>) — the widget's single source of
18
+ # truth: thread status, unread count for the launcher badge, and messages
19
+ # (all of them, or only those after the given id when polling).
20
+ def show
21
+ conversation = find_conversation
22
+ return render json: empty_state unless conversation
23
+
24
+ messages = conversation.messages.chronological
25
+ messages = messages.after_id(params[:after]) if params[:after].present?
26
+
27
+ render json: {
28
+ status: conversation.status,
29
+ unread: conversation.messages.from_agent.unread.count,
30
+ email: conversation.visitor_email.present?,
31
+ messages: messages.map(&:as_widget_json)
32
+ }
33
+ end
34
+
35
+ # POST widget/messages — first message starts the thread; writing into a
36
+ # resolved thread reopens it.
37
+ def create
38
+ message = nil
39
+ # One transaction, so an invalid first message never leaves an empty
40
+ # conversation behind.
41
+ ActiveRecord::Base.transaction do
42
+ conversation = find_conversation || start_conversation
43
+ message = conversation.post_visitor_message!(params[:body].to_s.strip)
44
+ end
45
+ refresh_context(message.conversation)
46
+ Notifications.visitor_message(message)
47
+
48
+ render json: { message: message.as_widget_json }, status: :created
49
+ rescue ActiveRecord::RecordInvalid => e
50
+ render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
51
+ end
52
+
53
+ # POST widget/email — a guest leaves an address so replies reach them
54
+ # when they're gone.
55
+ def email
56
+ conversation = find_conversation
57
+ return head :not_found unless conversation
58
+
59
+ if conversation.update(visitor_email: params[:email].to_s.strip.presence)
60
+ head :no_content
61
+ else
62
+ render json: { errors: conversation.errors.full_messages }, status: :unprocessable_entity
63
+ end
64
+ end
65
+
66
+ # POST widget/read — the visitor opened the panel and saw the replies.
67
+ def read
68
+ find_conversation&.mark_read_for_visitor!
69
+ head :no_content
70
+ end
71
+
72
+ private
73
+
74
+ def find_conversation
75
+ Conversation.for_visitor(visitor_id: current_visitor_id, visitor_token: visitor_token)
76
+ end
77
+
78
+ def start_conversation
79
+ Conversation.create!(
80
+ visitor_id: current_visitor_id,
81
+ visitor_token: current_visitor_id ? visitor_token : ensure_visitor_token,
82
+ visitor_label: visitor_label,
83
+ visitor_email: (current_visitor.try(:email).presence if current_visitor),
84
+ page_url: params[:page_url].to_s.first(255).presence,
85
+ locale: params[:locale].to_s.first(10).presence
86
+ )
87
+ end
88
+
89
+ def visitor_label
90
+ return unless current_visitor
91
+
92
+ Livechat.config.visitor_label.call(current_visitor).presence
93
+ end
94
+
95
+ # page_url and locale track the visitor's LAST message, not the first —
96
+ # "where are they stuck right now" is what an answering agent needs.
97
+ def refresh_context(conversation)
98
+ updates = {}
99
+ updates[:page_url] = params[:page_url].to_s.first(255) if params[:page_url].present?
100
+ updates[:locale] = params[:locale].to_s.first(10) if params[:locale].present?
101
+ conversation.update_columns(updates) if updates.any?
102
+ end
103
+
104
+ def empty_state
105
+ { status: nil, unread: 0, email: false, messages: [] }
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # Serves the widget JavaScript as a plain same-origin script. Same-origin
5
+ # matters: under a `script-src 'self'` (or nonce-based) CSP, an external
6
+ # script from the app's own host is always allowed — including when Turbo
7
+ # Drive swaps the <body> and re-runs body scripts under the *original*
8
+ # page's CSP header, where a freshly minted inline nonce would be refused.
9
+ class WidgetsController < ApplicationController
10
+ # The script is static and carries no user data; without this, Rails'
11
+ # cross-origin JavaScript guard refuses to serve it to a plain
12
+ # <script src> request.
13
+ skip_forgery_protection
14
+
15
+ # The script URLs carry a content fingerprint (?v=<md5>), so stale code
16
+ # is structurally impossible: new code means a new URL. The canonical
17
+ # fingerprinted URL is immutable and gets long-lived caching; anything
18
+ # else only ETag-revalidates.
19
+ def show
20
+ serve(Widget.javascript, Widget.fingerprint)
21
+ end
22
+
23
+ def dashboard
24
+ serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint)
25
+ end
26
+
27
+ private
28
+
29
+ def serve(source, fingerprint)
30
+ expires_in 1.year, public: true if params[:v] == fingerprint
31
+ return unless stale?(etag: [Livechat::VERSION, fingerprint])
32
+
33
+ render plain: source, content_type: 'text/javascript'
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # Included into the host's ActionView. Drop `<%= livechat_tag %>` before
5
+ # </body> in your layout; it renders nothing unless chat is enabled for the
6
+ # request. The widget shows a floating launcher (unless show_launcher is
7
+ # off) and also opens from any element carrying `data-livechat-open`, or
8
+ # from `window.Livechat.open()`.
9
+ module WidgetHelper
10
+ def livechat_tag
11
+ return unless Livechat.enabled?(request)
12
+
13
+ Widget.snippet(
14
+ locale: I18n.locale,
15
+ authenticated: livechat_visitor.present?,
16
+ nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
17
+ ).html_safe
18
+ end
19
+
20
+ # A plain, unstyled <button> that opens the chat — it picks up the host's
21
+ # own styles. Put it anywhere on a page that also renders livechat_tag.
22
+ def livechat_button(label: nil, **)
23
+ return unless Livechat.enabled?(request)
24
+
25
+ label ||= I18n.t(:launcher, scope: :livechat, default: 'Chat with us')
26
+ tag.button(label, type: 'button', 'data-livechat-open': '', **)
27
+ end
28
+
29
+ private
30
+
31
+ def livechat_visitor
32
+ return @livechat_visitor if defined?(@livechat_visitor)
33
+
34
+ @livechat_visitor = Livechat.config.current_user.call(request)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ # The two built-in notification emails. Plain text, no branding — they are
5
+ # nudges back into the conversation, not newsletters. Both require
6
+ # config.mailer_from; the callers in Livechat::Notifications enforce the
7
+ # rest of the conditions (recipients present, first unread message).
8
+ class Mailer < ActionMailer::Base
9
+ # To the team: a visitor wrote and nobody has read it.
10
+ def new_visitor_message(message)
11
+ @message = message
12
+ @conversation = message.conversation
13
+ @inbox_url = inbox_url(@conversation)
14
+
15
+ mail from: Livechat.config.mailer_from,
16
+ to: Livechat.config.agent_email_list,
17
+ subject: I18n.t('livechat.mail.visitor_subject',
18
+ name: @conversation.display_name, app: Livechat.app_name,
19
+ default: '%{name} wrote to you on %{app}')
20
+ end
21
+
22
+ # To the visitor: an agent replied while they were away.
23
+ def new_agent_reply(message)
24
+ @message = message
25
+ @conversation = message.conversation
26
+
27
+ mail from: Livechat.config.mailer_from,
28
+ to: @conversation.visitor_email,
29
+ subject: I18n.t('livechat.mail.reply_subject',
30
+ name: message.public_label, app: Livechat.app_name,
31
+ default: '%{name} replied to you on %{app}')
32
+ end
33
+
34
+ private
35
+
36
+ # An absolute link to the inbox needs a host. Reuse the one hosts already
37
+ # configure for Action Mailer (Devise needs it too); without one, the
38
+ # email simply carries no link.
39
+ def inbox_url(conversation)
40
+ host = default_url_options[:host] || ActionMailer::Base.default_url_options[:host]
41
+ return unless host
42
+
43
+ Livechat::Engine.routes.url_helpers.conversation_url(
44
+ conversation, host: host, **default_url_options.except(:host),
45
+ script_name: Livechat.config.mount_path.chomp('/')
46
+ )
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livechat
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end