livechat 0.5.4 → 0.6.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 +11 -0
- data/app/controllers/livechat/conversations_controller.rb +6 -0
- data/app/controllers/livechat/visitor_controller.rb +9 -1
- data/app/mailers/livechat/mailer.rb +20 -0
- data/app/models/livechat/conversation.rb +13 -0
- data/app/views/layouts/livechat/application.html.erb +2 -0
- data/app/views/livechat/conversations/show.html.erb +2 -0
- data/app/views/livechat/mailer/new_agent_reply.text.erb +11 -2
- data/app/views/livechat/mailer/new_visitor_message.text.erb +24 -2
- data/config/locales/livechat.ar.yml +2 -0
- data/config/locales/livechat.bg.yml +2 -0
- data/config/locales/livechat.bn.yml +2 -0
- data/config/locales/livechat.de.yml +2 -0
- data/config/locales/livechat.el.yml +2 -0
- data/config/locales/livechat.en.yml +2 -0
- data/config/locales/livechat.es.yml +2 -0
- data/config/locales/livechat.fr.yml +2 -0
- data/config/locales/livechat.hi.yml +2 -0
- data/config/locales/livechat.hr.yml +2 -0
- data/config/locales/livechat.id.yml +2 -0
- data/config/locales/livechat.it.yml +2 -0
- data/config/locales/livechat.ja.yml +2 -0
- data/config/locales/livechat.ko.yml +2 -0
- data/config/locales/livechat.lb.yml +2 -0
- data/config/locales/livechat.nl.yml +2 -0
- data/config/locales/livechat.pl.yml +2 -0
- data/config/locales/livechat.pt.yml +2 -0
- data/config/locales/livechat.ro.yml +2 -0
- data/config/locales/livechat.ru.yml +2 -0
- data/config/locales/livechat.th.yml +2 -0
- data/config/locales/livechat.tr.yml +2 -0
- data/config/locales/livechat.uk.yml +2 -0
- data/config/locales/livechat.ur.yml +2 -0
- data/config/locales/livechat.vi.yml +2 -0
- data/config/locales/livechat.zh-CN.yml +2 -0
- data/config/routes.rb +2 -0
- data/lib/livechat/dashboard.js +46 -3
- data/lib/livechat/version.rb +1 -1
- data/lib/livechat/widget.js +35 -1
- data/lib/livechat/widget.rb +2 -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: aee50137fc34157ea3fff75a2095055b4224c2d0ced2524ea13278135256b07f
|
|
4
|
+
data.tar.gz: 358aa67a9cb0a4b30ee961078cf4d8ae740da6fbbe04cbdcba987fc68c7e866f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45f8656ac4857d04970159bfc75052b75da1152b993d83a4c7c743589cda5e78cf5e47c2de7607c2c94d4fafdce880c50dd36b02000dcc2ec1e4a16343c4821e
|
|
7
|
+
data.tar.gz: c7e6e959cd01488ca58a8e59ed943da3233d9e9e3b6670aac19be11915a8595e8ec680bac0b4ad262a19b755be42da7bbf16af007e56815fed7f8aaffd7e5683
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Improved unread notification emails. Team emails now summarize the unread
|
|
6
|
+
visitor burst with up to five messages, attachment names, visitor email,
|
|
7
|
+
locale, page URL, and a count of any additional unread messages. Visitor
|
|
8
|
+
reply emails now summarize unread agent replies the same way.
|
|
9
|
+
- Added lightweight typing indicators. Visitors can see when an agent is
|
|
10
|
+
typing, and agents can see when the visitor is typing, using short-lived
|
|
11
|
+
cache hints over the existing polling endpoints. No database migration or
|
|
12
|
+
mandatory Action Cable setup required.
|
|
13
|
+
|
|
3
14
|
## 0.5.4
|
|
4
15
|
|
|
5
16
|
- Balanced the recording controls visually: the stop/finish square now uses
|
|
@@ -58,10 +58,16 @@ module Livechat
|
|
|
58
58
|
|
|
59
59
|
render json: {
|
|
60
60
|
status: @conversation.status,
|
|
61
|
+
typing: @conversation.typing?('visitor'),
|
|
61
62
|
messages: messages.map { |message| message.as_inbox_json(@conversation) }
|
|
62
63
|
}
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
def typing
|
|
67
|
+
@conversation.typing!('agent')
|
|
68
|
+
head :no_content
|
|
69
|
+
end
|
|
70
|
+
|
|
65
71
|
def resolve
|
|
66
72
|
@conversation.resolve_by!(current_agent_label)
|
|
67
73
|
redirect_to conversation_path(@conversation)
|
|
@@ -29,6 +29,7 @@ module Livechat
|
|
|
29
29
|
unread: conversation.messages.from_agent.unread.count,
|
|
30
30
|
email: conversation.visitor_email.present?,
|
|
31
31
|
cable: cable_payload(conversation),
|
|
32
|
+
typing: conversation.typing?('agent'),
|
|
32
33
|
messages: messages.map(&:as_widget_json)
|
|
33
34
|
}
|
|
34
35
|
end
|
|
@@ -70,6 +71,13 @@ module Livechat
|
|
|
70
71
|
head :no_content
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
# POST widget/typing — a short-lived hint for agents who are watching the
|
|
75
|
+
# inbox. It never starts a thread by itself; only real messages do that.
|
|
76
|
+
def typing
|
|
77
|
+
find_conversation&.typing!('visitor')
|
|
78
|
+
head :no_content
|
|
79
|
+
end
|
|
80
|
+
|
|
73
81
|
private
|
|
74
82
|
|
|
75
83
|
def find_conversation
|
|
@@ -103,7 +111,7 @@ module Livechat
|
|
|
103
111
|
end
|
|
104
112
|
|
|
105
113
|
def empty_state
|
|
106
|
-
{ status: nil, unread: 0, email: false, cable: nil, messages: [] }
|
|
114
|
+
{ status: nil, unread: 0, email: false, cable: nil, typing: false, messages: [] }
|
|
107
115
|
end
|
|
108
116
|
|
|
109
117
|
# The signed stream token and cable URL the widget subscribes with, or nil
|
|
@@ -6,11 +6,14 @@ module Livechat
|
|
|
6
6
|
# config.mailer_from; the callers in Livechat::Notifications enforce the
|
|
7
7
|
# rest of the conditions (recipients present, first unread message).
|
|
8
8
|
class Mailer < ActionMailer::Base
|
|
9
|
+
MAX_MESSAGES = 5
|
|
10
|
+
|
|
9
11
|
# To the team: a visitor wrote and nobody has read it.
|
|
10
12
|
def new_visitor_message(message)
|
|
11
13
|
@message = message
|
|
12
14
|
@conversation = message.conversation
|
|
13
15
|
@inbox_url = inbox_url(@conversation)
|
|
16
|
+
assign_unread_digest(message, @conversation.messages.from_visitor)
|
|
14
17
|
|
|
15
18
|
mail from: Livechat.config.mailer_from,
|
|
16
19
|
to: Livechat.config.agent_email_list,
|
|
@@ -23,6 +26,7 @@ module Livechat
|
|
|
23
26
|
def new_agent_reply(message)
|
|
24
27
|
@message = message
|
|
25
28
|
@conversation = message.conversation
|
|
29
|
+
assign_unread_digest(message, @conversation.messages.from_agent)
|
|
26
30
|
|
|
27
31
|
mail from: Livechat.config.mailer_from,
|
|
28
32
|
to: @conversation.visitor_email,
|
|
@@ -47,5 +51,21 @@ module Livechat
|
|
|
47
51
|
rescue StandardError
|
|
48
52
|
nil
|
|
49
53
|
end
|
|
54
|
+
|
|
55
|
+
def assign_unread_digest(message, side)
|
|
56
|
+
scope = side.unread.where(id: message.id..).chronological
|
|
57
|
+
unread_count = scope.count
|
|
58
|
+
@messages = scope.limit(MAX_MESSAGES).to_a
|
|
59
|
+
@messages = [message] if @messages.empty?
|
|
60
|
+
@message_count = [unread_count, @messages.size].max
|
|
61
|
+
@more_count = [@message_count - @messages.size, 0].max
|
|
62
|
+
@attachment_names = @messages.index_with { |digest_message| attachment_names(digest_message) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def attachment_names(message)
|
|
66
|
+
return [] unless message.files_attached?
|
|
67
|
+
|
|
68
|
+
message.attached_files.map { |file| file.filename.to_s }
|
|
69
|
+
end
|
|
50
70
|
end
|
|
51
71
|
end
|
|
@@ -7,6 +7,7 @@ module Livechat
|
|
|
7
7
|
# signed-in visitors are keyed by visitor_id, guests by a cookie token.
|
|
8
8
|
class Conversation < ApplicationRecord
|
|
9
9
|
STATUSES = %w[open resolved].freeze
|
|
10
|
+
TYPING_TTL = 8.seconds
|
|
10
11
|
|
|
11
12
|
has_many :messages, dependent: :destroy
|
|
12
13
|
|
|
@@ -100,6 +101,14 @@ module Livechat
|
|
|
100
101
|
visitor_label.presence || visitor_email.presence || "Visitor ##{id}"
|
|
101
102
|
end
|
|
102
103
|
|
|
104
|
+
def typing!(author_type)
|
|
105
|
+
Rails.cache.write(typing_cache_key(author_type), true, expires_in: TYPING_TTL)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def typing?(author_type)
|
|
109
|
+
Rails.cache.exist?(typing_cache_key(author_type))
|
|
110
|
+
end
|
|
111
|
+
|
|
103
112
|
private
|
|
104
113
|
|
|
105
114
|
# Attach only real uploads, and only where attachments are enabled — a
|
|
@@ -120,5 +129,9 @@ module Livechat
|
|
|
120
129
|
|
|
121
130
|
errors.add(:base, 'needs a visitor_id or a visitor_token')
|
|
122
131
|
end
|
|
132
|
+
|
|
133
|
+
def typing_cache_key(author_type)
|
|
134
|
+
"livechat/conversations/#{id}/typing/#{author_type}"
|
|
135
|
+
end
|
|
123
136
|
end
|
|
124
137
|
end
|
|
@@ -85,6 +85,8 @@
|
|
|
85
85
|
.thread .msg.agent { align-self: flex-end; background: var(--accent); color: var(--accent-text);
|
|
86
86
|
border-bottom-right-radius: 4px; }
|
|
87
87
|
.thread .system { align-self: center; color: var(--muted); font-size: 12px; margin: 8px 0; }
|
|
88
|
+
.thread .typing { align-self: flex-start; color: var(--muted); font-size: 12px;
|
|
89
|
+
font-style: italic; margin: 8px 4px 2px; }
|
|
88
90
|
.thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
|
89
91
|
.thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
|
|
90
92
|
.thread .msg .att-audio { display: block; width: 280px; max-width: 100%; height: 36px; }
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
<% cable = livechat_cable_data("livechat:conversation:#{@conversation.id}") %>
|
|
38
38
|
<div class="thread" id="thread"
|
|
39
39
|
data-poll-url="<%= poll_conversation_path(@conversation) %>"
|
|
40
|
+
data-typing-url="<%= typing_conversation_path(@conversation) %>"
|
|
41
|
+
data-typing-label="<%= t('livechat.dashboard.visitor_typing', default: 'Visitor is typing...') %>"
|
|
40
42
|
data-latest="<%= last&.id.to_i %>"
|
|
41
43
|
data-last-author="<%= (last && !last.system?) ? "#{last.author_type}:#{last.agent_label}" : '' %>"
|
|
42
44
|
<%= tag.attributes(data: cable) if cable.any? %>>
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
<%= @message.public_label %>:
|
|
1
|
+
<%= @message.public_label %> sent <%= @message_count %> unread <%= @message_count == 1 ? 'reply' : 'replies' %>:
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<% @messages.each do |message| %>
|
|
4
|
+
- <%= message.body.presence || '(no text)' %>
|
|
5
|
+
<% if @attachment_names[message].any? %>
|
|
6
|
+
Attachments: <%= @attachment_names[message].join(', ') %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<% if @more_count.positive? %>
|
|
11
|
+
...and <%= @more_count %> more unread <%= @more_count == 1 ? 'reply' : 'replies' %>.
|
|
12
|
+
<% end %>
|
|
4
13
|
|
|
5
14
|
<% if @conversation.page_url.present? %>
|
|
6
15
|
<%= t('livechat.mail.continue', default: 'Continue the conversation') %>: <%= @conversation.page_url %>
|
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
<%= @conversation.display_name %>:
|
|
1
|
+
<%= @conversation.display_name %> sent <%= @message_count %> unread <%= @message_count == 1 ? 'message' : 'messages' %>:
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<% @messages.each do |message| %>
|
|
4
|
+
- <%= message.body.presence || '(no text)' %>
|
|
5
|
+
<% if @attachment_names[message].any? %>
|
|
6
|
+
Attachments: <%= @attachment_names[message].join(', ') %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<% if @more_count.positive? %>
|
|
11
|
+
...and <%= @more_count %> more unread <%= @more_count == 1 ? 'message' : 'messages' %>.
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<% if @conversation.visitor_email.present? || @conversation.locale.present? || @conversation.page_url.present? %>
|
|
15
|
+
Context:
|
|
16
|
+
<% if @conversation.visitor_email.present? %>
|
|
17
|
+
Email: <%= @conversation.visitor_email %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% if @conversation.locale.present? %>
|
|
20
|
+
Locale: <%= @conversation.locale %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if @conversation.page_url.present? %>
|
|
23
|
+
Page: <%= @conversation.page_url %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
4
26
|
|
|
5
27
|
<% if @inbox_url %>
|
|
6
28
|
<%= t('livechat.mail.answer', default: 'Answer') %>: <%= @inbox_url %>
|
|
@@ -29,6 +29,7 @@ ar:
|
|
|
29
29
|
attachment_error: "تعذر إرسال بعض الملفات."
|
|
30
30
|
expand: "تكبير"
|
|
31
31
|
collapse: "تصغير"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "ملفات كثيرة جدًا (بحد أقصى %{count})."
|
|
34
35
|
too_large: "%{name} كبير جدًا."
|
|
@@ -62,6 +63,7 @@ ar:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "الأحدث"
|
|
64
65
|
older: "الأقدم"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "كتب لك %{name} على %{app}"
|
|
67
69
|
reply_subject: "رد عليك %{name} على %{app}"
|
|
@@ -29,6 +29,7 @@ bg:
|
|
|
29
29
|
attachment_error: "Някои файлове не можаха да бъдат изпратени."
|
|
30
30
|
expand: "Уголеми"
|
|
31
31
|
collapse: "Смали"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Твърде много файлове (макс. %{count})."
|
|
34
35
|
too_large: "%{name} е твърде голям."
|
|
@@ -62,6 +63,7 @@ bg:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "По-нови"
|
|
64
65
|
older: "По-стари"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} ви писа в %{app}"
|
|
67
69
|
reply_subject: "%{name} ви отговори в %{app}"
|
|
@@ -29,6 +29,7 @@ bn:
|
|
|
29
29
|
attachment_error: "কিছু ফাইল পাঠানো যায়নি।"
|
|
30
30
|
expand: "বড় করুন"
|
|
31
31
|
collapse: "ছোট করুন"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "অনেক বেশি ফাইল (সর্বোচ্চ %{count})।"
|
|
34
35
|
too_large: "%{name} খুব বড়।"
|
|
@@ -62,6 +63,7 @@ bn:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "নতুনতর"
|
|
64
65
|
older: "পুরনো"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} আপনাকে %{app}-এ লিখেছেন"
|
|
67
69
|
reply_subject: "%{name} আপনাকে %{app}-এ উত্তর দিয়েছেন"
|
|
@@ -29,6 +29,7 @@ de:
|
|
|
29
29
|
attachment_error: "Einige Dateien konnten nicht gesendet werden."
|
|
30
30
|
expand: "Vergrößern"
|
|
31
31
|
collapse: "Verkleinern"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Zu viele Dateien (max. %{count})."
|
|
34
35
|
too_large: "%{name} ist zu groß."
|
|
@@ -62,6 +63,7 @@ de:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Neuere"
|
|
64
65
|
older: "Ältere"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} hat Ihnen auf %{app} geschrieben"
|
|
67
69
|
reply_subject: "%{name} hat Ihnen auf %{app} geantwortet"
|
|
@@ -29,6 +29,7 @@ el:
|
|
|
29
29
|
attachment_error: "Ορισμένα αρχεία δεν ήταν δυνατό να σταλούν."
|
|
30
30
|
expand: "Μεγέθυνση"
|
|
31
31
|
collapse: "Σμίκρυνση"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Πάρα πολλά αρχεία (έως %{count})."
|
|
34
35
|
too_large: "Το %{name} είναι πολύ μεγάλο."
|
|
@@ -62,6 +63,7 @@ el:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Νεότερα"
|
|
64
65
|
older: "Παλαιότερα"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "Ο/Η %{name} σας έγραψε στο %{app}"
|
|
67
69
|
reply_subject: "Ο/Η %{name} σας απάντησε στο %{app}"
|
|
@@ -29,6 +29,7 @@ en:
|
|
|
29
29
|
attachment_error: "Some files could not be sent."
|
|
30
30
|
expand: "Expand"
|
|
31
31
|
collapse: "Collapse"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Too many files (max %{count})."
|
|
34
35
|
too_large: "%{name} is too large."
|
|
@@ -62,6 +63,7 @@ en:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Newer"
|
|
64
65
|
older: "Older"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} wrote to you on %{app}"
|
|
67
69
|
reply_subject: "%{name} replied to you on %{app}"
|
|
@@ -29,6 +29,7 @@ es:
|
|
|
29
29
|
attachment_error: "No se pudieron enviar algunos archivos."
|
|
30
30
|
expand: "Ampliar"
|
|
31
31
|
collapse: "Reducir"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Demasiados archivos (máx. %{count})."
|
|
34
35
|
too_large: "%{name} es demasiado grande."
|
|
@@ -62,6 +63,7 @@ es:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Más recientes"
|
|
64
65
|
older: "Más antiguos"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} te escribió en %{app}"
|
|
67
69
|
reply_subject: "%{name} te respondió en %{app}"
|
|
@@ -29,6 +29,7 @@ fr:
|
|
|
29
29
|
attachment_error: "Certains fichiers n'ont pas pu être envoyés."
|
|
30
30
|
expand: "Agrandir"
|
|
31
31
|
collapse: "Réduire"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Trop de fichiers (max %{count})."
|
|
34
35
|
too_large: "%{name} est trop volumineux."
|
|
@@ -62,6 +63,7 @@ fr:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Plus récents"
|
|
64
65
|
older: "Plus anciens"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} vous a écrit sur %{app}"
|
|
67
69
|
reply_subject: "%{name} vous a répondu sur %{app}"
|
|
@@ -29,6 +29,7 @@ hi:
|
|
|
29
29
|
attachment_error: "कुछ फ़ाइलें भेजी नहीं जा सकीं।"
|
|
30
30
|
expand: "बड़ा करें"
|
|
31
31
|
collapse: "छोटा करें"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "बहुत सारी फ़ाइलें (अधिकतम %{count})।"
|
|
34
35
|
too_large: "%{name} बहुत बड़ी है।"
|
|
@@ -62,6 +63,7 @@ hi:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "नए"
|
|
64
65
|
older: "पुराने"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} ने आपको %{app} पर लिखा"
|
|
67
69
|
reply_subject: "%{name} ने आपको %{app} पर जवाब दिया"
|
|
@@ -29,6 +29,7 @@ hr:
|
|
|
29
29
|
attachment_error: "Neke datoteke nije bilo moguće poslati."
|
|
30
30
|
expand: "Povećaj"
|
|
31
31
|
collapse: "Smanji"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Previše datoteka (najviše %{count})."
|
|
34
35
|
too_large: "%{name} je prevelika."
|
|
@@ -62,6 +63,7 @@ hr:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Novije"
|
|
64
65
|
older: "Starije"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} vam je pisao/la na %{app}"
|
|
67
69
|
reply_subject: "%{name} vam je odgovorio/la na %{app}"
|
|
@@ -29,6 +29,7 @@ id:
|
|
|
29
29
|
attachment_error: "Beberapa file tidak dapat dikirim."
|
|
30
30
|
expand: "Perbesar"
|
|
31
31
|
collapse: "Perkecil"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Terlalu banyak file (maks. %{count})."
|
|
34
35
|
too_large: "%{name} terlalu besar."
|
|
@@ -62,6 +63,7 @@ id:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Lebih baru"
|
|
64
65
|
older: "Lebih lama"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} menulis kepada Anda di %{app}"
|
|
67
69
|
reply_subject: "%{name} membalas Anda di %{app}"
|
|
@@ -29,6 +29,7 @@ it:
|
|
|
29
29
|
attachment_error: "Non è stato possibile inviare alcuni file."
|
|
30
30
|
expand: "Ingrandisci"
|
|
31
31
|
collapse: "Riduci"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Troppi file (max %{count})."
|
|
34
35
|
too_large: "%{name} è troppo grande."
|
|
@@ -62,6 +63,7 @@ it:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Più recenti"
|
|
64
65
|
older: "Più vecchi"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} ti ha scritto su %{app}"
|
|
67
69
|
reply_subject: "%{name} ti ha risposto su %{app}"
|
|
@@ -29,6 +29,7 @@ ja:
|
|
|
29
29
|
attachment_error: "一部のファイルを送信できませんでした。"
|
|
30
30
|
expand: "拡大"
|
|
31
31
|
collapse: "縮小"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "ファイルが多すぎます(最大%{count}件)。"
|
|
34
35
|
too_large: "%{name}が大きすぎます。"
|
|
@@ -62,6 +63,7 @@ ja:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "新しい"
|
|
64
65
|
older: "古い"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name}さんが%{app}であなたにメッセージを送りました"
|
|
67
69
|
reply_subject: "%{name}さんが%{app}であなたに返信しました"
|
|
@@ -29,6 +29,7 @@ ko:
|
|
|
29
29
|
attachment_error: "일부 파일을 보내지 못했습니다."
|
|
30
30
|
expand: "확대"
|
|
31
31
|
collapse: "축소"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "파일이 너무 많습니다 (최대 %{count}개)."
|
|
34
35
|
too_large: "%{name}이(가) 너무 큽니다."
|
|
@@ -62,6 +63,7 @@ ko:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "최신"
|
|
64
65
|
older: "이전"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name}님이 %{app}에서 메시지를 보냈습니다"
|
|
67
69
|
reply_subject: "%{name}님이 %{app}에서 답장했습니다"
|
|
@@ -29,6 +29,7 @@ lb:
|
|
|
29
29
|
attachment_error: "E puer Dateie konnten net geschéckt ginn."
|
|
30
30
|
expand: "Vergréisseren"
|
|
31
31
|
collapse: "Verklengeren"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Ze vill Dateien (max %{count})."
|
|
34
35
|
too_large: "%{name} ass ze grouss."
|
|
@@ -62,6 +63,7 @@ lb:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Méi nei"
|
|
64
65
|
older: "Méi al"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} huet Iech op %{app} geschriwwen"
|
|
67
69
|
reply_subject: "%{name} huet Iech op %{app} geäntwert"
|
|
@@ -29,6 +29,7 @@ nl:
|
|
|
29
29
|
attachment_error: "Sommige bestanden konden niet worden verzonden."
|
|
30
30
|
expand: "Vergroten"
|
|
31
31
|
collapse: "Verkleinen"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Te veel bestanden (max. %{count})."
|
|
34
35
|
too_large: "%{name} is te groot."
|
|
@@ -62,6 +63,7 @@ nl:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Nieuwer"
|
|
64
65
|
older: "Ouder"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} heeft je een bericht gestuurd op %{app}"
|
|
67
69
|
reply_subject: "%{name} heeft je geantwoord op %{app}"
|
|
@@ -29,6 +29,7 @@ pl:
|
|
|
29
29
|
attachment_error: "Nie udało się wysłać niektórych plików."
|
|
30
30
|
expand: "Powiększ"
|
|
31
31
|
collapse: "Zmniejsz"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Zbyt wiele plików (maks. %{count})."
|
|
34
35
|
too_large: "%{name} jest za duży."
|
|
@@ -62,6 +63,7 @@ pl:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Nowsze"
|
|
64
65
|
older: "Starsze"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} napisał(a) do Ciebie w %{app}"
|
|
67
69
|
reply_subject: "%{name} odpowiedział(a) Ci w %{app}"
|
|
@@ -29,6 +29,7 @@ pt:
|
|
|
29
29
|
attachment_error: "Não foi possível enviar alguns arquivos."
|
|
30
30
|
expand: "Ampliar"
|
|
31
31
|
collapse: "Reduzir"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Muitos arquivos (máx. %{count})."
|
|
34
35
|
too_large: "%{name} é muito grande."
|
|
@@ -62,6 +63,7 @@ pt:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Mais recentes"
|
|
64
65
|
older: "Mais antigas"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} escreveu para você no %{app}"
|
|
67
69
|
reply_subject: "%{name} respondeu a você no %{app}"
|
|
@@ -29,6 +29,7 @@ ro:
|
|
|
29
29
|
attachment_error: "Unele fișiere nu au putut fi trimise."
|
|
30
30
|
expand: "Mărește"
|
|
31
31
|
collapse: "Micșorează"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Prea multe fișiere (max. %{count})."
|
|
34
35
|
too_large: "%{name} este prea mare."
|
|
@@ -62,6 +63,7 @@ ro:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Mai noi"
|
|
64
65
|
older: "Mai vechi"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} v-a scris pe %{app}"
|
|
67
69
|
reply_subject: "%{name} v-a răspuns pe %{app}"
|
|
@@ -29,6 +29,7 @@ ru:
|
|
|
29
29
|
attachment_error: "Некоторые файлы не удалось отправить."
|
|
30
30
|
expand: "Развернуть"
|
|
31
31
|
collapse: "Свернуть"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Слишком много файлов (макс. %{count})."
|
|
34
35
|
too_large: "%{name} слишком большой."
|
|
@@ -62,6 +63,7 @@ ru:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Новее"
|
|
64
65
|
older: "Старше"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} написал(а) вам в %{app}"
|
|
67
69
|
reply_subject: "%{name} ответил(а) вам в %{app}"
|
|
@@ -29,6 +29,7 @@ th:
|
|
|
29
29
|
attachment_error: "ไม่สามารถส่งไฟล์บางไฟล์ได้"
|
|
30
30
|
expand: "ขยาย"
|
|
31
31
|
collapse: "ย่อ"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "ไฟล์มากเกินไป (สูงสุด %{count})"
|
|
34
35
|
too_large: "%{name} มีขนาดใหญ่เกินไป"
|
|
@@ -62,6 +63,7 @@ th:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "ใหม่กว่า"
|
|
64
65
|
older: "เก่ากว่า"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} เขียนถึงคุณบน %{app}"
|
|
67
69
|
reply_subject: "%{name} ตอบกลับคุณบน %{app}"
|
|
@@ -29,6 +29,7 @@ tr:
|
|
|
29
29
|
attachment_error: "Bazı dosyalar gönderilemedi."
|
|
30
30
|
expand: "Büyüt"
|
|
31
31
|
collapse: "Küçült"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Çok fazla dosya (en fazla %{count})."
|
|
34
35
|
too_large: "%{name} çok büyük."
|
|
@@ -62,6 +63,7 @@ tr:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Daha yeni"
|
|
64
65
|
older: "Daha eski"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name}, %{app} üzerinden size yazdı"
|
|
67
69
|
reply_subject: "%{name}, %{app} üzerinden size yanıt verdi"
|
|
@@ -29,6 +29,7 @@ uk:
|
|
|
29
29
|
attachment_error: "Деякі файли не вдалося надіслати."
|
|
30
30
|
expand: "Розгорнути"
|
|
31
31
|
collapse: "Згорнути"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Забагато файлів (максимум %{count})."
|
|
34
35
|
too_large: "%{name} завеликий."
|
|
@@ -62,6 +63,7 @@ uk:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Новіші"
|
|
64
65
|
older: "Старіші"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} написав(ла) вам у %{app}"
|
|
67
69
|
reply_subject: "%{name} відповів(ла) вам у %{app}"
|
|
@@ -29,6 +29,7 @@ ur:
|
|
|
29
29
|
attachment_error: "کچھ فائلیں نہیں بھیجی جا سکیں۔"
|
|
30
30
|
expand: "بڑا کریں"
|
|
31
31
|
collapse: "چھوٹا کریں"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "بہت زیادہ فائلیں (زیادہ سے زیادہ %{count})۔"
|
|
34
35
|
too_large: "%{name} بہت بڑی ہے۔"
|
|
@@ -62,6 +63,7 @@ ur:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "نئے"
|
|
64
65
|
older: "پرانے"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} نے آپ کو %{app} پر لکھا"
|
|
67
69
|
reply_subject: "%{name} نے آپ کو %{app} پر جواب دیا"
|
|
@@ -29,6 +29,7 @@ vi:
|
|
|
29
29
|
attachment_error: "Không thể gửi một số tệp."
|
|
30
30
|
expand: "Mở rộng"
|
|
31
31
|
collapse: "Thu gọn"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "Quá nhiều tệp (tối đa %{count})."
|
|
34
35
|
too_large: "%{name} quá lớn."
|
|
@@ -62,6 +63,7 @@ vi:
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "Mới hơn"
|
|
64
65
|
older: "Cũ hơn"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} đã nhắn tin cho bạn trên %{app}"
|
|
67
69
|
reply_subject: "%{name} đã trả lời bạn trên %{app}"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
attachment_error: "部分文件无法发送。"
|
|
30
30
|
expand: "放大"
|
|
31
31
|
collapse: "缩小"
|
|
32
|
+
typing: "%{name} is typing..."
|
|
32
33
|
attachments:
|
|
33
34
|
too_many: "文件过多(最多 %{count} 个)。"
|
|
34
35
|
too_large: "%{name} 太大。"
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
microphone_error: "Could not start audio recording."
|
|
63
64
|
newer: "较新"
|
|
64
65
|
older: "较旧"
|
|
66
|
+
visitor_typing: "Visitor is typing..."
|
|
65
67
|
mail:
|
|
66
68
|
visitor_subject: "%{name} 在 %{app} 上给您留言"
|
|
67
69
|
reply_subject: "%{name} 在 %{app} 上回复了您"
|
data/config/routes.rb
CHANGED
|
@@ -9,6 +9,7 @@ Livechat::Engine.routes.draw do
|
|
|
9
9
|
scope :widget, as: :widget do
|
|
10
10
|
get 'conversation', to: 'visitor#show'
|
|
11
11
|
post 'messages', to: 'visitor#create'
|
|
12
|
+
post 'typing', to: 'visitor#typing'
|
|
12
13
|
post 'email', to: 'visitor#email'
|
|
13
14
|
post 'read', to: 'visitor#read'
|
|
14
15
|
end
|
|
@@ -26,6 +27,7 @@ Livechat::Engine.routes.draw do
|
|
|
26
27
|
end
|
|
27
28
|
member do
|
|
28
29
|
get :poll
|
|
30
|
+
post :typing
|
|
29
31
|
post :resolve
|
|
30
32
|
post :reopen
|
|
31
33
|
end
|
data/lib/livechat/dashboard.js
CHANGED
|
@@ -345,6 +345,13 @@
|
|
|
345
345
|
var latest = parseInt(thread.getAttribute("data-latest"), 10) || 0;
|
|
346
346
|
var lastAuthorKey = thread.getAttribute("data-last-author") || null;
|
|
347
347
|
var url = thread.getAttribute("data-poll-url");
|
|
348
|
+
var typingUrl = thread.getAttribute("data-typing-url");
|
|
349
|
+
var lastTypingSentAt = 0;
|
|
350
|
+
var typingEl = document.createElement("p");
|
|
351
|
+
typingEl.className = "typing";
|
|
352
|
+
typingEl.hidden = true;
|
|
353
|
+
typingEl.textContent = thread.getAttribute("data-typing-label") || "Visitor is typing...";
|
|
354
|
+
thread.appendChild(typingEl);
|
|
348
355
|
|
|
349
356
|
if (replyBox) {
|
|
350
357
|
replyBox.addEventListener("keydown", function (event) {
|
|
@@ -359,7 +366,10 @@
|
|
|
359
366
|
replyBox.style.height = "auto";
|
|
360
367
|
replyBox.style.height = Math.min(replyBox.scrollHeight, 160) + "px";
|
|
361
368
|
};
|
|
362
|
-
replyBox.addEventListener("input",
|
|
369
|
+
replyBox.addEventListener("input", function () {
|
|
370
|
+
autogrow();
|
|
371
|
+
noteTyping();
|
|
372
|
+
});
|
|
363
373
|
autogrow();
|
|
364
374
|
}
|
|
365
375
|
|
|
@@ -377,6 +387,7 @@
|
|
|
377
387
|
line.textContent = message.text + " · " + message.at;
|
|
378
388
|
thread.appendChild(line);
|
|
379
389
|
lastAuthorKey = null;
|
|
390
|
+
keepTypingLast();
|
|
380
391
|
return;
|
|
381
392
|
}
|
|
382
393
|
|
|
@@ -386,6 +397,7 @@
|
|
|
386
397
|
who.className = "who " + message.author;
|
|
387
398
|
who.textContent = message.name + " · " + message.at;
|
|
388
399
|
thread.appendChild(who);
|
|
400
|
+
keepTypingLast();
|
|
389
401
|
lastAuthorKey = key;
|
|
390
402
|
}
|
|
391
403
|
var bubble = document.createElement("div");
|
|
@@ -394,6 +406,7 @@
|
|
|
394
406
|
if (message.body) bubble.appendChild(document.createTextNode(message.body));
|
|
395
407
|
appendAttachments(bubble, message.attachments);
|
|
396
408
|
thread.appendChild(bubble);
|
|
409
|
+
keepTypingLast();
|
|
397
410
|
}
|
|
398
411
|
|
|
399
412
|
// Mirrors the server-rendered thread: images inline, other files as links,
|
|
@@ -441,19 +454,49 @@
|
|
|
441
454
|
fetch(url + "?after=" + latest, { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
|
442
455
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
443
456
|
.then(function (data) {
|
|
444
|
-
if (!data
|
|
445
|
-
data.
|
|
457
|
+
if (!data) return;
|
|
458
|
+
setTyping(!!data.typing);
|
|
459
|
+
if (data.messages && data.messages.length) data.messages.forEach(append);
|
|
446
460
|
if (atBottom) thread.scrollTop = thread.scrollHeight;
|
|
447
461
|
})
|
|
448
462
|
.catch(function () { /* transient network error — next tick retries */ });
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
function setTyping(show) {
|
|
466
|
+
typingEl.hidden = !show;
|
|
467
|
+
keepTypingLast();
|
|
468
|
+
if (show && stick) thread.scrollTop = thread.scrollHeight;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function keepTypingLast() {
|
|
472
|
+
if (thread.lastChild !== typingEl) thread.appendChild(typingEl);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function noteTyping() {
|
|
476
|
+
if (!typingUrl || !replyBox.value.trim()) return;
|
|
477
|
+
var now = Date.now();
|
|
478
|
+
if (now - lastTypingSentAt < 3000) return;
|
|
479
|
+
lastTypingSentAt = now;
|
|
480
|
+
fetch(typingUrl, {
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: csrfHeaders(),
|
|
483
|
+
credentials: "same-origin"
|
|
484
|
+
}).catch(function () { /* typing hints are best-effort */ });
|
|
485
|
+
}
|
|
486
|
+
|
|
451
487
|
setInterval(refresh, POLL_MS);
|
|
452
488
|
// Action Cable (opt-in): a nudge fetches new messages at once instead of
|
|
453
489
|
// waiting for the next tick. Polling stays the fallback.
|
|
454
490
|
openCable(thread, refresh);
|
|
455
491
|
}
|
|
456
492
|
|
|
493
|
+
function csrfHeaders() {
|
|
494
|
+
var headers = { Accept: "application/json" };
|
|
495
|
+
var token = document.querySelector('meta[name="csrf-token"]');
|
|
496
|
+
if (token) headers["X-CSRF-Token"] = token.content;
|
|
497
|
+
return headers;
|
|
498
|
+
}
|
|
499
|
+
|
|
457
500
|
// A tiny Action Cable client over the native WebSocket protocol — no
|
|
458
501
|
// @rails/actioncable dependency, no build step. It opens only when the
|
|
459
502
|
// element carries a signed stream (push turned on); otherwise polling alone
|
data/lib/livechat/version.rb
CHANGED
data/lib/livechat/widget.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
var errorEl = null;
|
|
35
35
|
var fileInputEl = null;
|
|
36
36
|
var filesBarEl = null;
|
|
37
|
+
var typingEl = null;
|
|
37
38
|
var recordBtnEl = null;
|
|
38
39
|
var recordingBarEl = null;
|
|
39
40
|
var recordingTimeEl = null;
|
|
@@ -69,6 +70,7 @@
|
|
|
69
70
|
var sending = false;
|
|
70
71
|
var pollTimer = null;
|
|
71
72
|
var fetching = false;
|
|
73
|
+
var lastTypingSentAt = 0;
|
|
72
74
|
// Action Cable (opt-in). The widget learns its signed stream from the poll
|
|
73
75
|
// response — a per-conversation token it never has to guess — and opens a
|
|
74
76
|
// socket that nudges it to poll the instant a reply lands. cableDisabled
|
|
@@ -263,6 +265,11 @@
|
|
|
263
265
|
greeting.className = "lvc-msg lvc-agent";
|
|
264
266
|
greeting.textContent = config.labels.greeting;
|
|
265
267
|
listEl.appendChild(greeting);
|
|
268
|
+
typingEl = document.createElement("div");
|
|
269
|
+
typingEl.className = "lvc-typing";
|
|
270
|
+
typingEl.hidden = true;
|
|
271
|
+
typingEl.textContent = config.labels.typing;
|
|
272
|
+
listEl.appendChild(typingEl);
|
|
266
273
|
panel.appendChild(listEl);
|
|
267
274
|
|
|
268
275
|
errorEl = document.createElement("div");
|
|
@@ -295,7 +302,10 @@
|
|
|
295
302
|
inputEl.rows = 1;
|
|
296
303
|
inputEl.placeholder = config.labels.placeholder;
|
|
297
304
|
inputEl.setAttribute("aria-label", config.labels.placeholder);
|
|
298
|
-
inputEl.addEventListener("input",
|
|
305
|
+
inputEl.addEventListener("input", function () {
|
|
306
|
+
autogrow();
|
|
307
|
+
noteTyping();
|
|
308
|
+
});
|
|
299
309
|
inputEl.addEventListener("keydown", function (event) {
|
|
300
310
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
301
311
|
event.preventDefault();
|
|
@@ -881,6 +891,7 @@
|
|
|
881
891
|
// Only an open panel renders (and thereby "consumes") messages;
|
|
882
892
|
// while closed, the poll feeds the badge and nothing else.
|
|
883
893
|
(data.messages || []).forEach(appendMessage);
|
|
894
|
+
setTyping(!!data.typing);
|
|
884
895
|
if (data.unread > 0) request("POST", "/read");
|
|
885
896
|
setUnread(0);
|
|
886
897
|
syncEmailRow();
|
|
@@ -941,6 +952,7 @@
|
|
|
941
952
|
: config.labels.eventReopened;
|
|
942
953
|
listEl.appendChild(line);
|
|
943
954
|
lastAuthorKey = null;
|
|
955
|
+
keepTypingLast();
|
|
944
956
|
scrollToBottom();
|
|
945
957
|
return;
|
|
946
958
|
}
|
|
@@ -964,9 +976,21 @@
|
|
|
964
976
|
renderAttachments(bubble, message.attachments);
|
|
965
977
|
bubble.title = new Date(message.at).toLocaleString();
|
|
966
978
|
listEl.appendChild(bubble);
|
|
979
|
+
keepTypingLast();
|
|
967
980
|
scrollToBottom();
|
|
968
981
|
}
|
|
969
982
|
|
|
983
|
+
function setTyping(show) {
|
|
984
|
+
if (!typingEl) return;
|
|
985
|
+
typingEl.hidden = !show;
|
|
986
|
+
keepTypingLast();
|
|
987
|
+
if (show) scrollToBottom();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function keepTypingLast() {
|
|
991
|
+
if (typingEl && listEl && listEl.lastChild !== typingEl) listEl.appendChild(typingEl);
|
|
992
|
+
}
|
|
993
|
+
|
|
970
994
|
// Images show inline (a thumbnail linking to the full file); everything
|
|
971
995
|
// else is a labelled download link. Both hit the engine's gated route.
|
|
972
996
|
function renderAttachments(bubble, attachments) {
|
|
@@ -1010,6 +1034,14 @@
|
|
|
1010
1034
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
|
1011
1035
|
}
|
|
1012
1036
|
|
|
1037
|
+
function noteTyping() {
|
|
1038
|
+
if (!hasThread || !inputEl || !inputEl.value.trim()) return;
|
|
1039
|
+
var now = Date.now();
|
|
1040
|
+
if (now - lastTypingSentAt < 3000) return;
|
|
1041
|
+
lastTypingSentAt = now;
|
|
1042
|
+
request("POST", "/typing").catch(function () { /* typing hints are best-effort */ });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1013
1045
|
// --- realtime (optional) ----------------------------------------------------
|
|
1014
1046
|
|
|
1015
1047
|
// A tiny Action Cable client over the native WebSocket protocol — no
|
|
@@ -1207,6 +1239,8 @@
|
|
|
1207
1239
|
"#lvc-root[dir=rtl] .lvc-agent{align-self:flex-end;border-bottom-left-radius:14px;" +
|
|
1208
1240
|
"border-bottom-right-radius:4px}" +
|
|
1209
1241
|
"#lvc-root .lvc-system{align-self:center;color:var(--lvc-muted);font-size:12px;margin:8px 0}" +
|
|
1242
|
+
"#lvc-root .lvc-typing{align-self:flex-start;color:var(--lvc-muted);font-size:12px;" +
|
|
1243
|
+
"margin:6px 4px 2px;font-style:italic}" +
|
|
1210
1244
|
"#lvc-error{padding:6px 14px;color:#dc2626;font-size:13px}" +
|
|
1211
1245
|
"#lvc-email{padding:8px 14px;border-top:1px solid var(--lvc-border);font-size:12px;" +
|
|
1212
1246
|
"color:var(--lvc-muted)}" +
|
data/lib/livechat/widget.rb
CHANGED
|
@@ -114,7 +114,8 @@ module Livechat
|
|
|
114
114
|
removeFile: t(:remove_file, 'Remove file'),
|
|
115
115
|
attachmentError: t(:attachment_error, 'Some files could not be sent.'),
|
|
116
116
|
expand: t(:expand, 'Expand'),
|
|
117
|
-
collapse: t(:collapse, 'Collapse')
|
|
117
|
+
collapse: t(:collapse, 'Collapse'),
|
|
118
|
+
typing: t(:typing, '%{name} is typing...', name: t(:team, 'Support'))
|
|
118
119
|
}
|
|
119
120
|
end
|
|
120
121
|
|