@hellotext/hellotext 2.4.3 → 2.4.5

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.
@@ -19,6 +19,9 @@ const MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
19
19
  minute: '2-digit',
20
20
  }
21
21
 
22
+ const MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i
23
+ const SCROLL_ISOLATION_EVENT_OPTIONS = { capture: true, passive: true }
24
+
22
25
  export default class extends Controller {
23
26
  static messageTimestampFormatters = {}
24
27
 
@@ -83,16 +86,22 @@ export default class extends Controller {
83
86
 
84
87
  this.files = []
85
88
  this.messageIds = new Set()
89
+ this.catchUpAfterMessageId = null
90
+ this.fetchingCatchUpMessages = false
86
91
 
87
92
  this.onMessageReceived = this.onMessageReceived.bind(this)
88
93
  this.onMessageReaction = this.onMessageReaction.bind(this)
89
94
  this.onTypingStart = this.onTypingStart.bind(this)
95
+ this.captureCatchUpCursor = this.captureCatchUpCursor.bind(this)
96
+ this.catchUpMessages = this.catchUpMessages.bind(this)
90
97
 
91
98
  this.onScroll = this.onScroll.bind(this)
92
99
 
93
100
  this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this)
94
101
  this.closePopoverOnEscape = this.closePopoverOnEscape.bind(this)
95
102
  this.broadcastChannel = new BroadcastChannel(`hellotext--webchat--${this.idValue}`)
103
+ this.webChatChannel.onDisconnect(this.captureCatchUpCursor)
104
+ this.webChatChannel.onReconnect(this.catchUpMessages)
96
105
 
97
106
  super.initialize()
98
107
  }
@@ -122,7 +131,18 @@ export default class extends Controller {
122
131
 
123
132
  this.webChatChannel.onReaction(this.onMessageReaction)
124
133
 
134
+ this.setupMessagesContainerScrollIsolation()
125
135
  this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
136
+ this.messagesContainerTarget.addEventListener(
137
+ 'wheel',
138
+ this.stopHostScrollPropagation,
139
+ SCROLL_ISOLATION_EVENT_OPTIONS,
140
+ )
141
+ this.messagesContainerTarget.addEventListener(
142
+ 'touchmove',
143
+ this.stopHostScrollPropagation,
144
+ SCROLL_ISOLATION_EVENT_OPTIONS,
145
+ )
126
146
 
127
147
  if (this.shouldOpenOnMount) {
128
148
  this.openValue = true
@@ -144,6 +164,16 @@ export default class extends Controller {
144
164
 
145
165
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
146
166
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
167
+ this.messagesContainerTarget.removeEventListener(
168
+ 'wheel',
169
+ this.stopHostScrollPropagation,
170
+ SCROLL_ISOLATION_EVENT_OPTIONS,
171
+ )
172
+ this.messagesContainerTarget.removeEventListener(
173
+ 'touchmove',
174
+ this.stopHostScrollPropagation,
175
+ SCROLL_ISOLATION_EVENT_OPTIONS,
176
+ )
147
177
  window.removeEventListener('keydown', this.closePopoverOnEscape, true)
148
178
 
149
179
  // Clean up typing indicator timeouts
@@ -155,6 +185,20 @@ export default class extends Controller {
155
185
  super.disconnect()
156
186
  }
157
187
 
188
+ setupMessagesContainerScrollIsolation() {
189
+ this.messagesContainerTarget.style.overscrollBehavior = 'contain'
190
+ this.messagesContainerTarget.style.webkitOverflowScrolling = 'touch'
191
+ this.messagesContainerTarget.style.touchAction = 'pan-y'
192
+
193
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent', '')
194
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent-wheel', '')
195
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent-touch', '')
196
+ }
197
+
198
+ stopHostScrollPropagation(event) {
199
+ event.stopPropagation()
200
+ }
201
+
158
202
  onTypingStart() {
159
203
  if (this.typingIndicatorVisible) {
160
204
  return this.resetTypingIndicatorTimer()
@@ -294,7 +338,14 @@ export default class extends Controller {
294
338
 
295
339
  const element = this.messageTemplateTarget.cloneNode(true)
296
340
 
341
+ element.classList.add('hellotext--webchat-message')
297
342
  element.setAttribute('data-hellotext--webchat-target', 'message')
343
+ element.setAttribute('data-id', message.id)
344
+
345
+ if (createdAt) {
346
+ element.setAttribute('data-created-at', createdAt)
347
+ }
348
+
298
349
  element.style.removeProperty('display')
299
350
 
300
351
  element.querySelector('[data-body]').innerHTML = div.innerHTML
@@ -373,7 +424,7 @@ export default class extends Controller {
373
424
  this.dismissTeaserForSession?.()
374
425
 
375
426
  if (!this.onMobile) {
376
- this.inputTarget.focus()
427
+ this.focusComposeInput()
377
428
  }
378
429
 
379
430
  if (!this.scrolled) {
@@ -434,7 +485,7 @@ export default class extends Controller {
434
485
  }
435
486
  }
436
487
 
437
- onMessageReceived(message) {
488
+ onMessageReceived(message, options = {}) {
438
489
  const { id, body, attachments, teaser } = message
439
490
  const createdAt = message.created_at || message.createdAt
440
491
 
@@ -443,19 +494,21 @@ export default class extends Controller {
443
494
  this.hideTeaser?.()
444
495
 
445
496
  if (message.carousel) {
446
- return this.insertCarouselMessage(message)
497
+ return this.insertCarouselMessage(message, options)
447
498
  }
448
499
 
449
500
  const div = document.createElement('div')
450
501
  div.innerHTML = body
451
502
 
452
503
  const element = this.messageTemplateTarget.cloneNode(true)
504
+ element.classList.add('hellotext--webchat-message')
453
505
  element.style.display = 'flex'
454
506
 
455
507
  element.querySelector('[data-body]').innerHTML = div.innerHTML
456
508
 
457
509
  element.setAttribute('data-id', id)
458
510
  element.setAttribute('data-hellotext--webchat-target', 'message')
511
+ this.setMessageCreatedAt(element, createdAt)
459
512
  this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
460
513
 
461
514
  if (attachments) {
@@ -469,14 +522,16 @@ export default class extends Controller {
469
522
  }
470
523
 
471
524
  this.clearTypingIndicator()
472
- this.messagesContainerTarget.appendChild(element)
525
+ this.insertMessageElement(element)
473
526
 
474
527
  Hellotext.eventEmitter.dispatch('webchat:message:received', {
475
528
  ...message,
476
529
  body: element.querySelector('[data-body]').innerText,
477
530
  })
478
531
 
479
- element.scrollIntoView({ behavior: 'smooth' })
532
+ if (options.scroll !== false) {
533
+ element.scrollIntoView({ behavior: 'smooth' })
534
+ }
480
535
 
481
536
  this.updateMessageTeaser(teaser)
482
537
 
@@ -501,6 +556,70 @@ export default class extends Controller {
501
556
  return !messageTargets.some(element => element.dataset.id === id)
502
557
  }
503
558
 
559
+ captureCatchUpCursor() {
560
+ this.catchUpAfterMessageId = this.lastRenderedMessageId
561
+ }
562
+
563
+ async catchUpMessages() {
564
+ const afterId = this.catchUpAfterMessageId
565
+
566
+ if (!afterId || this.fetchingCatchUpMessages) return
567
+
568
+ this.fetchingCatchUpMessages = true
569
+
570
+ try {
571
+ const response = await this.messagesAPI.catchUp(afterId)
572
+ const { messages = [] } = await response.json()
573
+
574
+ messages.forEach(message => this.onMessageReceived(message, { scroll: false }))
575
+ this.catchUpAfterMessageId = this.lastRenderedMessageId
576
+ } finally {
577
+ this.fetchingCatchUpMessages = false
578
+ }
579
+ }
580
+
581
+ get lastRenderedMessageId() {
582
+ const messages = this.persistedMessageElements
583
+
584
+ return messages[messages.length - 1]?.dataset.id || null
585
+ }
586
+
587
+ get persistedMessageElements() {
588
+ return Array.from(
589
+ this.messagesContainerTarget.querySelectorAll('.hellotext--webchat-message[data-id]'),
590
+ )
591
+ }
592
+
593
+ setMessageCreatedAt(element, createdAt) {
594
+ if (createdAt) {
595
+ element.setAttribute('data-created-at', createdAt)
596
+ }
597
+ }
598
+
599
+ insertMessageElement(element) {
600
+ const nextElement = this.nextMessageElementFor(element)
601
+
602
+ if (nextElement) {
603
+ this.messagesContainerTarget.insertBefore(element, nextElement)
604
+ } else {
605
+ this.messagesContainerTarget.appendChild(element)
606
+ }
607
+ }
608
+
609
+ nextMessageElementFor(element) {
610
+ const createdAt = Date.parse(element.dataset.createdAt)
611
+
612
+ if (Number.isNaN(createdAt)) return null
613
+
614
+ return this.persistedMessageElements.find(messageElement => {
615
+ if (messageElement === element) return false
616
+
617
+ const messageCreatedAt = Date.parse(messageElement.dataset.createdAt)
618
+
619
+ return !Number.isNaN(messageCreatedAt) && messageCreatedAt > createdAt
620
+ })
621
+ }
622
+
504
623
  updateMessageTeaser(teaser) {
505
624
  this.messageTeaserValue = teaser
506
625
 
@@ -520,18 +639,23 @@ export default class extends Controller {
520
639
  this.teaserTarget.classList.toggle('invisible', this.openValue)
521
640
  }
522
641
 
523
- insertCarouselMessage(message) {
642
+ insertCarouselMessage(message, options = {}) {
524
643
  const html = message.html
644
+ const createdAt = message.created_at || message.createdAt
525
645
  const element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild
526
646
 
647
+ element.classList.add('hellotext--webchat-message')
527
648
  element.setAttribute('data-id', message.id)
528
649
  element.setAttribute('data-hellotext--webchat-target', 'message')
650
+ this.setMessageCreatedAt(element, createdAt)
529
651
  this.localizeMessageTimestamps(element)
530
652
 
531
653
  this.clearTypingIndicator()
532
- this.messagesContainerTarget.appendChild(element)
654
+ this.insertMessageElement(element)
533
655
 
534
- element.scrollIntoView({ behavior: 'smooth' })
656
+ if (options.scroll !== false) {
657
+ element.scrollIntoView({ behavior: 'smooth' })
658
+ }
535
659
 
536
660
  Hellotext.eventEmitter.dispatch('webchat:message:received', {
537
661
  ...message,
@@ -612,7 +736,10 @@ export default class extends Controller {
612
736
  const data = await response.json()
613
737
 
614
738
  this.dispatch('set:id', { target: element, detail: data.id })
615
- this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
739
+ this.localizeMessageTimestamp(
740
+ element.querySelector('[data-message-timestamp]'),
741
+ data.created_at || data.createdAt,
742
+ )
616
743
  this.clearRevealedOpeningSequenceMessageIds()
617
744
 
618
745
  const message = {
@@ -686,7 +813,10 @@ export default class extends Controller {
686
813
 
687
814
  const data = await response.json()
688
815
  element.setAttribute('data-id', data.id)
689
- this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
816
+ this.localizeMessageTimestamp(
817
+ element.querySelector('[data-message-timestamp]'),
818
+ data.created_at || data.createdAt,
819
+ )
690
820
  this.clearRevealedOpeningSequenceMessageIds()
691
821
 
692
822
  Hellotext.eventEmitter.dispatch('webchat:message:sent', {
@@ -783,7 +913,7 @@ export default class extends Controller {
783
913
  this.attachmentContainerTarget.style.display = 'none'
784
914
  this.errorMessageContainerTarget.style.display = 'none'
785
915
 
786
- this.inputTarget.focus()
916
+ this.focusComposeInput()
787
917
 
788
918
  // Set up optimistic typing indicator BEFORE making the API call
789
919
  // This prevents race conditions with server responses
@@ -806,7 +936,10 @@ export default class extends Controller {
806
936
  const data = await response.json()
807
937
  element.setAttribute('data-id', data.id)
808
938
  message.id = data.id
809
- this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
939
+ this.localizeMessageTimestamp(
940
+ element.querySelector('[data-message-timestamp]'),
941
+ data.created_at || data.createdAt,
942
+ )
810
943
  this.clearRevealedOpeningSequenceMessageIds()
811
944
 
812
945
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
@@ -856,19 +989,18 @@ export default class extends Controller {
856
989
 
857
990
  if (!this.hasInputTarget || target.closest(ignoredSelector)) return
858
991
 
859
- event.preventDefault()
860
- this.inputTarget.focus()
992
+ if (!this.focusComposeInput({ moveCursorToEnd: true })) return
861
993
 
862
- if (typeof this.inputTarget.selectionStart === 'number') {
863
- const position = this.inputTarget.value.length
864
- this.inputTarget.setSelectionRange(position, position)
865
- }
994
+ event.preventDefault()
866
995
  }
867
996
 
868
997
  closePopoverFromHeader(event) {
869
998
  const { target } = event
870
999
 
871
- if (target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')) return
1000
+ if (
1001
+ target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')
1002
+ )
1003
+ return
872
1004
 
873
1005
  event.preventDefault()
874
1006
  this.closePopover()
@@ -1049,7 +1181,7 @@ export default class extends Controller {
1049
1181
  this.errorMessageContainerTarget.innerText = ''
1050
1182
 
1051
1183
  newFiles.forEach(file => this.createAttachmentElement(file))
1052
- this.inputTarget.focus()
1184
+ this.focusComposeInput()
1053
1185
  }
1054
1186
 
1055
1187
  createAttachmentElement(file) {
@@ -1091,7 +1223,7 @@ export default class extends Controller {
1091
1223
  this.attachmentInputTarget.value = ''
1092
1224
 
1093
1225
  attachment.remove()
1094
- this.inputTarget.focus()
1226
+ this.focusComposeInput()
1095
1227
  }
1096
1228
 
1097
1229
  attachmentTargetDisconnected() {
@@ -1119,7 +1251,22 @@ export default class extends Controller {
1119
1251
  this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end)
1120
1252
 
1121
1253
  this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length
1254
+ this.focusComposeInput()
1255
+ }
1256
+
1257
+ focusComposeInput({ moveCursorToEnd = false } = {}) {
1258
+ if (!this.shouldAutofocusCompose) return false
1259
+ if (this.hasInputTarget === false) return false
1260
+ if (this.hasInputTarget === undefined && !this.inputTarget) return false
1261
+
1122
1262
  this.inputTarget.focus()
1263
+
1264
+ if (moveCursorToEnd && typeof this.inputTarget.selectionStart === 'number') {
1265
+ const position = this.inputTarget.value.length
1266
+ this.inputTarget.setSelectionRange(position, position)
1267
+ }
1268
+
1269
+ return true
1123
1270
  }
1124
1271
 
1125
1272
  byteToMegabyte(bytes) {
@@ -1136,7 +1283,32 @@ export default class extends Controller {
1136
1283
  )
1137
1284
  }
1138
1285
 
1286
+ get shouldAutofocusCompose() {
1287
+ return !this.usesVirtualKeyboard
1288
+ }
1289
+
1290
+ get usesVirtualKeyboard() {
1291
+ if (typeof navigator === 'undefined') return false
1292
+
1293
+ const userAgent = navigator.userAgent || ''
1294
+ const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
1295
+ const isMobileUserAgent = MOBILE_USER_AGENT_PATTERN.test(userAgent)
1296
+ const isUserAgentDataMobile = navigator.userAgentData?.mobile === true
1297
+
1298
+ return isMobileUserAgent || isIPadOS || isUserAgentDataMobile || this.hasTouchOnlyPointer
1299
+ }
1300
+
1301
+ get hasTouchOnlyPointer() {
1302
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
1303
+
1304
+ return (
1305
+ window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches
1306
+ )
1307
+ }
1308
+
1139
1309
  get onMobile() {
1310
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
1311
+
1140
1312
  return window.matchMedia(`(max-width: ${this.fullScreenThresholdValue}px)`).matches
1141
1313
  }
1142
1314
  }