@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.
- package/dist/hellotext.js +1 -1
- package/lib/api/webchat/messages.cjs +8 -0
- package/lib/api/webchat/messages.js +8 -0
- package/lib/channels/application_channel.cjs +143 -10
- package/lib/channels/application_channel.js +143 -10
- package/lib/channels/webchat_channel.cjs +57 -0
- package/lib/channels/webchat_channel.js +57 -0
- package/lib/controllers/webchat_controller.cjs +167 -22
- package/lib/controllers/webchat_controller.js +172 -18
- package/package.json +1 -1
- package/src/api/webchat/messages.js +7 -0
- package/src/channels/application_channel.js +152 -9
- package/src/channels/webchat_channel.js +61 -1
- package/src/controllers/webchat_controller.js +193 -21
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
654
|
+
this.insertMessageElement(element)
|
|
533
655
|
|
|
534
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
860
|
-
this.inputTarget.focus()
|
|
992
|
+
if (!this.focusComposeInput({ moveCursorToEnd: true })) return
|
|
861
993
|
|
|
862
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
}
|