@hellotext/hellotext 2.1.3 → 2.1.4

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.
@@ -23,6 +23,10 @@ export default class extends Controller {
23
23
  disabled: { type: Boolean, default: false },
24
24
  nextPage: { type: Number, default: undefined },
25
25
  fullScreenThreshold: { type: Number, default: 1024 },
26
+ typingIndicatorKeepAlive: { type: Number, default: 30000 }, // 30 seconds
27
+ offset: { type: Number, default: 24 },
28
+ padding: { type: Number, default: 24 },
29
+ optimisticTypingIndicatorWait: { type: Number, default: 1000 }, // 1 second
26
30
  }
27
31
 
28
32
  static classes = ['fadeOut']
@@ -40,12 +44,13 @@ export default class extends Controller {
40
44
  'messageTemplate',
41
45
  'messagesContainer',
42
46
  'title',
43
- 'onlineStatus',
44
47
  'attachmentImage',
45
48
  'footer',
46
49
  'toolbar',
47
50
  'message',
48
51
  'unreadCounter',
52
+ 'typingIndicator',
53
+ 'typingIndicatorTemplate',
49
54
  ]
50
55
 
51
56
  initialize() {
@@ -60,9 +65,8 @@ export default class extends Controller {
60
65
  this.files = []
61
66
 
62
67
  this.onMessageReceived = this.onMessageReceived.bind(this)
63
- this.onConversationAssignment = this.onConversationAssignment.bind(this)
64
- this.onAgentOnline = this.onAgentOnline.bind(this)
65
68
  this.onMessageReaction = this.onMessageReaction.bind(this)
69
+ this.onTypingStart = this.onTypingStart.bind(this)
66
70
 
67
71
  this.onScroll = this.onScroll.bind(this)
68
72
 
@@ -81,9 +85,8 @@ export default class extends Controller {
81
85
  this.setupFloatingUI({ trigger: this.triggerTarget, popover: this.popoverTarget })
82
86
 
83
87
  this.webChatChannel.onMessage(this.onMessageReceived)
84
- this.webChatChannel.onConversationAssignment(this.onConversationAssignment)
88
+ this.webChatChannel.onTypingStart(this.onTypingStart)
85
89
 
86
- this.webChatChannel.onAgentOnline(this.onAgentOnline)
87
90
  this.webChatChannel.onReaction(this.onMessageReaction)
88
91
 
89
92
  this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
@@ -106,12 +109,94 @@ export default class extends Controller {
106
109
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
107
110
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
108
111
 
112
+ // Clean up typing indicator timeouts
113
+ this.clearTypingIndicator()
114
+
109
115
  this.broadcastChannel.close()
110
116
  this.floatingUICleanup()
111
117
 
112
118
  super.disconnect()
113
119
  }
114
120
 
121
+ onTypingStart() {
122
+ if (this.typingIndicatorVisible) {
123
+ return this.resetTypingIndicatorTimer()
124
+ }
125
+
126
+ this.showTypingIndicator()
127
+ }
128
+
129
+ showOptimisticTypingIndicator() {
130
+ if (this.typingIndicatorVisible) return
131
+
132
+ this.showTypingIndicator()
133
+ }
134
+
135
+ showTypingIndicator() {
136
+ this.clearTypingIndicator()
137
+
138
+ this.typingIndicatorVisible = true
139
+ const indicator = this.typingIndicatorTemplateTarget.cloneNode(true)
140
+
141
+ indicator.setAttribute('data-hellotext--webchat-target', 'typingIndicator')
142
+ indicator.style.display = 'flex'
143
+
144
+ this.messagesContainerTarget.appendChild(indicator)
145
+
146
+ requestAnimationFrame(() => {
147
+ this.messagesContainerTarget.scroll({
148
+ top: this.messagesContainerTarget.scrollHeight,
149
+ behavior: 'instant',
150
+ })
151
+ })
152
+
153
+ const timeout = this.typingIndicatorKeepAliveValue
154
+
155
+ this.incomingTypingIndicatorTimeout = setTimeout(() => {
156
+ this.clearTypingIndicator()
157
+ }, timeout)
158
+ }
159
+
160
+ resetTypingIndicatorTimer() {
161
+ if (!this.typingIndicatorVisible) return
162
+
163
+ // Clear existing timeout
164
+ clearTimeout(this.incomingTypingIndicatorTimeout)
165
+ clearTimeout(this.optimisticTypingTimeout)
166
+
167
+ // Use unified timeout for all typing indicators
168
+ const timeout = this.typingIndicatorKeepAliveValue
169
+
170
+ this.incomingTypingIndicatorTimeout = setTimeout(() => {
171
+ this.clearTypingIndicator()
172
+ }, timeout)
173
+ }
174
+
175
+ clearTypingIndicator() {
176
+ if (this.hasTypingIndicatorTarget) {
177
+ this.typingIndicatorTarget.remove()
178
+ }
179
+
180
+ this.typingIndicatorVisible = false
181
+
182
+ clearTimeout(this.incomingTypingIndicatorTimeout)
183
+ clearTimeout(this.optimisticTypingTimeout)
184
+ }
185
+
186
+ onMessageInputChange() {
187
+ this.resizeInput()
188
+ clearTimeout(this.typingIndicatorTimeout)
189
+
190
+ if (!this.hasSentTypingIndicator) {
191
+ this.webChatChannel.startTypingIndicator()
192
+ this.hasSentTypingIndicator = true
193
+ }
194
+
195
+ this.typingIndicatorTimeout = setTimeout(() => {
196
+ this.hasSentTypingIndicator = false
197
+ }, 3000)
198
+ }
199
+
115
200
  onOutboundMessageSent(event) {
116
201
  const { data } = event
117
202
 
@@ -120,7 +205,13 @@ export default class extends Controller {
120
205
  const element = new DOMParser().parseFromString(data.element, 'text/html').body
121
206
  .firstElementChild
122
207
 
123
- this.messagesContainerTarget.appendChild(element)
208
+ // Insert message before typing indicator if one exists
209
+ if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
210
+ this.messagesContainerTarget.insertBefore(element, this.typingIndicatorTarget)
211
+ } else {
212
+ this.messagesContainerTarget.appendChild(element)
213
+ }
214
+
124
215
  element.scrollIntoView({ behavior: 'instant' })
125
216
  },
126
217
  'message:failed': data => {
@@ -295,6 +386,11 @@ export default class extends Controller {
295
386
  })
296
387
  }
297
388
 
389
+ // Clear any typing indicator when message is received
390
+ if (this.typingIndicatorVisible) {
391
+ this.clearTypingIndicator()
392
+ }
393
+
298
394
  this.messagesContainerTarget.appendChild(element)
299
395
 
300
396
  Hellotext.eventEmitter.dispatch('webchat:message:received', {
@@ -303,7 +399,6 @@ export default class extends Controller {
303
399
  })
304
400
 
305
401
  element.scrollIntoView({ behavior: 'smooth' })
306
- this.setOfflineTimeout()
307
402
 
308
403
  if (this.openValue) {
309
404
  this.messagesAPI.markAsSeen(id)
@@ -316,24 +411,20 @@ export default class extends Controller {
316
411
  this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
317
412
  }
318
413
 
319
- onConversationAssignment(conversation) {
320
- const { to: user } = conversation
414
+ resizeInput() {
415
+ const maxHeight = 96
321
416
 
322
- this.titleTarget.innerText = user.name
417
+ // Temporarily reset height to its natural content size
418
+ this.inputTarget.style.height = 'auto'
323
419
 
324
- if (user.online) {
325
- this.onlineStatusTarget.style.display = 'flex'
326
- } else {
327
- this.onlineStatusTarget.style.display = 'none'
328
- }
329
- }
420
+ // Set the height to the scrollHeight, which is the minimum height
421
+ // the element needs to fit its content without a scrollbar.
422
+ const scrollHeight = this.inputTarget.scrollHeight
330
423
 
331
- onAgentOnline() {
332
- this.onlineStatusTarget.style.display = 'flex'
333
- this.setOfflineTimeout()
424
+ this.inputTarget.style.height = `${Math.min(scrollHeight, maxHeight)}px`
334
425
  }
335
426
 
336
- async sendMessage() {
427
+ async sendMessage(e) {
337
428
  const formData = new FormData()
338
429
 
339
430
  const message = {
@@ -342,6 +433,10 @@ export default class extends Controller {
342
433
  }
343
434
 
344
435
  if (this.inputTarget.value.trim().length === 0 && this.files.length === 0) {
436
+ if (e && e.target) {
437
+ e.preventDefault()
438
+ }
439
+
345
440
  return
346
441
  }
347
442
 
@@ -381,7 +476,13 @@ export default class extends Controller {
381
476
  })
382
477
  }
383
478
 
384
- this.messagesContainerTarget.appendChild(element)
479
+ // Insert message before typing indicator if one exists
480
+ if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
481
+ this.messagesContainerTarget.insertBefore(element, this.typingIndicatorTarget)
482
+ } else {
483
+ this.messagesContainerTarget.appendChild(element)
484
+ }
485
+
385
486
  element.scrollIntoView({ behavior: 'smooth' })
386
487
 
387
488
  this.broadcastChannel.postMessage({
@@ -390,6 +491,7 @@ export default class extends Controller {
390
491
  })
391
492
 
392
493
  this.inputTarget.value = ''
494
+ this.resizeInput()
393
495
  this.files = []
394
496
 
395
497
  this.attachmentInputTarget.value = ''
@@ -422,6 +524,15 @@ export default class extends Controller {
422
524
  this.webChatChannel.updateSubscriptionWith(this.conversationIdValue)
423
525
  }
424
526
 
527
+ if (this.typingIndicatorVisible) {
528
+ this.resetTypingIndicatorTimer()
529
+ } else {
530
+ clearTimeout(this.optimisticTypingTimeout)
531
+ this.optimisticTypingTimeout = setTimeout(() => {
532
+ this.showOptimisticTypingIndicator()
533
+ }, this.optimisticTypingIndicatorWaitValue)
534
+ }
535
+
425
536
  this.attachmentContainerTarget.style.display = ''
426
537
  }
427
538
 
@@ -534,24 +645,12 @@ export default class extends Controller {
534
645
  this.inputTarget.focus()
535
646
  }
536
647
 
537
- setOfflineTimeout() {
538
- clearTimeout(this.offlineTimeout)
539
-
540
- this.offlineTimeout = setTimeout(() => {
541
- this.onlineStatusTarget.style.display = 'none'
542
- }, this.fiveMinutes)
543
- }
544
-
545
648
  byteToMegabyte(bytes) {
546
649
  return Math.ceil(bytes / 1024 / 1024)
547
650
  }
548
651
 
549
- get fiveMinutes() {
550
- return 300000
551
- }
552
-
553
652
  get middlewares() {
554
- return [offset(5), shift({ padding: 24 }), flip()]
653
+ return [offset(this.offsetValue), shift({ padding: this.paddingValue }), flip()]
555
654
  }
556
655
 
557
656
  get shouldOpenOnMount() {
@@ -14,6 +14,7 @@ import { Webchat } from './configuration/webchat'
14
14
  */
15
15
  class Configuration {
16
16
  static apiRoot = 'https://api.hellotext.com/v1'
17
+ static actionCableUrl = 'wss://www.hellotext.com/cable'
17
18
 
18
19
  static autoGenerateSession = true
19
20
  static session = null
@@ -1,5 +1,3 @@
1
- import { Cookies } from './cookies'
2
-
3
1
  class Query {
4
2
  static get inPreviewMode() {
5
3
  return new this().inPreviewMode
@@ -22,7 +20,7 @@ class Query {
22
20
  }
23
21
 
24
22
  get session() {
25
- return this.get('session') || Cookies.get('hello_session')
23
+ return this.get('session')
26
24
  }
27
25
 
28
26
  toHellotextParam(param) {
@@ -18,7 +18,7 @@ class Session {
18
18
  static initialize() {
19
19
  this.#query = new Query()
20
20
 
21
- this.session = Configuration.session || this.#query.session || Cookies.get('hello_session')
21
+ this.session = this.#query.session || Configuration.session || Cookies.get('hello_session')
22
22
 
23
23
  if (!this.session && Configuration.autoGenerateSession) {
24
24
  this.session = crypto.randomUUID()