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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/app/controllers/livechat/conversations_controller.rb +6 -0
  4. data/app/controllers/livechat/visitor_controller.rb +9 -1
  5. data/app/mailers/livechat/mailer.rb +20 -0
  6. data/app/models/livechat/conversation.rb +13 -0
  7. data/app/views/layouts/livechat/application.html.erb +2 -0
  8. data/app/views/livechat/conversations/show.html.erb +2 -0
  9. data/app/views/livechat/mailer/new_agent_reply.text.erb +11 -2
  10. data/app/views/livechat/mailer/new_visitor_message.text.erb +24 -2
  11. data/config/locales/livechat.ar.yml +2 -0
  12. data/config/locales/livechat.bg.yml +2 -0
  13. data/config/locales/livechat.bn.yml +2 -0
  14. data/config/locales/livechat.de.yml +2 -0
  15. data/config/locales/livechat.el.yml +2 -0
  16. data/config/locales/livechat.en.yml +2 -0
  17. data/config/locales/livechat.es.yml +2 -0
  18. data/config/locales/livechat.fr.yml +2 -0
  19. data/config/locales/livechat.hi.yml +2 -0
  20. data/config/locales/livechat.hr.yml +2 -0
  21. data/config/locales/livechat.id.yml +2 -0
  22. data/config/locales/livechat.it.yml +2 -0
  23. data/config/locales/livechat.ja.yml +2 -0
  24. data/config/locales/livechat.ko.yml +2 -0
  25. data/config/locales/livechat.lb.yml +2 -0
  26. data/config/locales/livechat.nl.yml +2 -0
  27. data/config/locales/livechat.pl.yml +2 -0
  28. data/config/locales/livechat.pt.yml +2 -0
  29. data/config/locales/livechat.ro.yml +2 -0
  30. data/config/locales/livechat.ru.yml +2 -0
  31. data/config/locales/livechat.th.yml +2 -0
  32. data/config/locales/livechat.tr.yml +2 -0
  33. data/config/locales/livechat.uk.yml +2 -0
  34. data/config/locales/livechat.ur.yml +2 -0
  35. data/config/locales/livechat.vi.yml +2 -0
  36. data/config/locales/livechat.zh-CN.yml +2 -0
  37. data/config/routes.rb +2 -0
  38. data/lib/livechat/dashboard.js +46 -3
  39. data/lib/livechat/version.rb +1 -1
  40. data/lib/livechat/widget.js +35 -1
  41. data/lib/livechat/widget.rb +2 -1
  42. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f04a8c3aee0c2e7c9f4f3eff556bf450f4f81844750d8ebfb80d058ac49e180
4
- data.tar.gz: 0f62d39bd16d1e02836fe8dbd8a777652e466db06406c60108a3ef75c3321161
3
+ metadata.gz: aee50137fc34157ea3fff75a2095055b4224c2d0ced2524ea13278135256b07f
4
+ data.tar.gz: 358aa67a9cb0a4b30ee961078cf4d8ae740da6fbbe04cbdcba987fc68c7e866f
5
5
  SHA512:
6
- metadata.gz: 2c3cbfefe5b627c5e919b9306ea72509026e107d28e5a69c5e83923286d6bf7e360ee5c8bfb5b0481883267dbf61630e1cfae40819d31760e0121463602e2cc6
7
- data.tar.gz: b1c8c7782956dd04291838094131f904970011cfb92ed08473a359ad02046af18c88fc04b38d3c0e917361db5e7de3b79871fcb15c49a13d1d666d94a751a248
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
- <%= @message.body %>
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
- <%= @message.body %>
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
@@ -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", autogrow);
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 || !data.messages || !data.messages.length) return;
445
- data.messages.forEach(append);
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.5.4'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -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", autogrow);
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)}" +
@@ -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
 
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.5.4
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov