@hellotext/hellotext 2.4.2 → 2.4.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.
- package/dist/hellotext.js +1 -1
- package/lib/controllers/message_controller.cjs +144 -25
- package/lib/controllers/message_controller.js +145 -25
- package/lib/controllers/mixins/usePopover.cjs +2 -0
- package/lib/controllers/mixins/usePopover.js +2 -0
- package/lib/controllers/webchat/useTeaser.cjs +3 -1
- package/lib/controllers/webchat/useTeaser.js +3 -1
- package/lib/controllers/webchat_controller.cjs +219 -25
- package/lib/controllers/webchat_controller.js +233 -25
- package/lib/core/configuration.cjs +19 -0
- package/lib/core/configuration.js +19 -0
- package/lib/models/business.cjs +86 -5
- package/lib/models/business.js +82 -5
- package/lib/models/webchat.cjs +18 -3
- package/lib/models/webchat.js +27 -6
- package/package.json +1 -1
- package/src/controllers/message_controller.js +145 -22
- package/src/controllers/mixins/usePopover.js +1 -0
- package/src/controllers/webchat/useTeaser.js +4 -1
- package/src/controllers/webchat_controller.js +255 -27
- package/src/core/configuration.js +25 -0
- package/src/models/business.js +86 -5
- package/src/models/webchat.js +22 -3
|
@@ -13,7 +13,16 @@ import { useBehaviour } from './webchat/useBehaviour'
|
|
|
13
13
|
import { useOpeningSequence } from './webchat/useOpeningSequence'
|
|
14
14
|
import { useTeaser } from './webchat/useTeaser'
|
|
15
15
|
|
|
16
|
+
const POPOVER_ANIMATION_DURATION = 120
|
|
17
|
+
const MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
|
|
18
|
+
hour: 'numeric',
|
|
19
|
+
minute: '2-digit',
|
|
20
|
+
}
|
|
21
|
+
const SCROLL_ISOLATION_EVENT_OPTIONS = { capture: true, passive: true }
|
|
22
|
+
|
|
16
23
|
export default class extends Controller {
|
|
24
|
+
static messageTimestampFormatters = {}
|
|
25
|
+
|
|
17
26
|
static values = {
|
|
18
27
|
id: String,
|
|
19
28
|
conversationId: String,
|
|
@@ -83,6 +92,7 @@ export default class extends Controller {
|
|
|
83
92
|
this.onScroll = this.onScroll.bind(this)
|
|
84
93
|
|
|
85
94
|
this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this)
|
|
95
|
+
this.closePopoverOnEscape = this.closePopoverOnEscape.bind(this)
|
|
86
96
|
this.broadcastChannel = new BroadcastChannel(`hellotext--webchat--${this.idValue}`)
|
|
87
97
|
|
|
88
98
|
super.initialize()
|
|
@@ -106,13 +116,25 @@ export default class extends Controller {
|
|
|
106
116
|
|
|
107
117
|
this.setupTeaser()
|
|
108
118
|
this.setupOpeningSequence()
|
|
119
|
+
this.localizeMessageTimestamps()
|
|
109
120
|
|
|
110
121
|
this.webChatChannel.onMessage(this.onMessageReceived)
|
|
111
122
|
this.webChatChannel.onTypingStart(this.onTypingStart)
|
|
112
123
|
|
|
113
124
|
this.webChatChannel.onReaction(this.onMessageReaction)
|
|
114
125
|
|
|
126
|
+
this.setupMessagesContainerScrollIsolation()
|
|
115
127
|
this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
|
|
128
|
+
this.messagesContainerTarget.addEventListener(
|
|
129
|
+
'wheel',
|
|
130
|
+
this.stopHostScrollPropagation,
|
|
131
|
+
SCROLL_ISOLATION_EVENT_OPTIONS,
|
|
132
|
+
)
|
|
133
|
+
this.messagesContainerTarget.addEventListener(
|
|
134
|
+
'touchmove',
|
|
135
|
+
this.stopHostScrollPropagation,
|
|
136
|
+
SCROLL_ISOLATION_EVENT_OPTIONS,
|
|
137
|
+
)
|
|
116
138
|
|
|
117
139
|
if (this.shouldOpenOnMount) {
|
|
118
140
|
this.openValue = true
|
|
@@ -120,6 +142,7 @@ export default class extends Controller {
|
|
|
120
142
|
|
|
121
143
|
Hellotext.eventEmitter.dispatch('webchat:mounted')
|
|
122
144
|
this.broadcastChannel.addEventListener('message', this.onOutboundMessageSent)
|
|
145
|
+
window.addEventListener('keydown', this.closePopoverOnEscape, true)
|
|
123
146
|
this.scheduleBehaviourOpen()
|
|
124
147
|
|
|
125
148
|
super.connect()
|
|
@@ -127,11 +150,23 @@ export default class extends Controller {
|
|
|
127
150
|
|
|
128
151
|
disconnect() {
|
|
129
152
|
this.cancelBehaviourOpen()
|
|
153
|
+
this.clearPopoverOpenAnimation()
|
|
130
154
|
this.teardownTeaser()
|
|
131
155
|
this.teardownOpeningSequence()
|
|
132
156
|
|
|
133
157
|
this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
|
|
134
158
|
this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
|
|
159
|
+
this.messagesContainerTarget.removeEventListener(
|
|
160
|
+
'wheel',
|
|
161
|
+
this.stopHostScrollPropagation,
|
|
162
|
+
SCROLL_ISOLATION_EVENT_OPTIONS,
|
|
163
|
+
)
|
|
164
|
+
this.messagesContainerTarget.removeEventListener(
|
|
165
|
+
'touchmove',
|
|
166
|
+
this.stopHostScrollPropagation,
|
|
167
|
+
SCROLL_ISOLATION_EVENT_OPTIONS,
|
|
168
|
+
)
|
|
169
|
+
window.removeEventListener('keydown', this.closePopoverOnEscape, true)
|
|
135
170
|
|
|
136
171
|
// Clean up typing indicator timeouts
|
|
137
172
|
this.clearTypingIndicator()
|
|
@@ -142,6 +177,20 @@ export default class extends Controller {
|
|
|
142
177
|
super.disconnect()
|
|
143
178
|
}
|
|
144
179
|
|
|
180
|
+
setupMessagesContainerScrollIsolation() {
|
|
181
|
+
this.messagesContainerTarget.style.overscrollBehavior = 'contain'
|
|
182
|
+
this.messagesContainerTarget.style.webkitOverflowScrolling = 'touch'
|
|
183
|
+
this.messagesContainerTarget.style.touchAction = 'pan-y'
|
|
184
|
+
|
|
185
|
+
this.messagesContainerTarget.setAttribute('data-lenis-prevent', '')
|
|
186
|
+
this.messagesContainerTarget.setAttribute('data-lenis-prevent-wheel', '')
|
|
187
|
+
this.messagesContainerTarget.setAttribute('data-lenis-prevent-touch', '')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
stopHostScrollPropagation(event) {
|
|
191
|
+
event.stopPropagation()
|
|
192
|
+
}
|
|
193
|
+
|
|
145
194
|
onTypingStart() {
|
|
146
195
|
if (this.typingIndicatorVisible) {
|
|
147
196
|
return this.resetTypingIndicatorTimer()
|
|
@@ -228,6 +277,7 @@ export default class extends Controller {
|
|
|
228
277
|
'message:sent': data => {
|
|
229
278
|
const element = new DOMParser().parseFromString(data.element, 'text/html').body
|
|
230
279
|
.firstElementChild
|
|
280
|
+
this.localizeMessageTimestamps(element)
|
|
231
281
|
|
|
232
282
|
// Insert message before typing indicator if one exists
|
|
233
283
|
if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
|
|
@@ -239,7 +289,9 @@ export default class extends Controller {
|
|
|
239
289
|
element.scrollIntoView({ behavior: 'instant' })
|
|
240
290
|
},
|
|
241
291
|
'message:failed': data => {
|
|
242
|
-
this.messagesContainerTarget.querySelector(`#${data.id}`)
|
|
292
|
+
const element = this.messagesContainerTarget.querySelector(`#${data.id}`)
|
|
293
|
+
|
|
294
|
+
this.markMessageFailed(element, data.reason)
|
|
243
295
|
},
|
|
244
296
|
}
|
|
245
297
|
|
|
@@ -271,6 +323,7 @@ export default class extends Controller {
|
|
|
271
323
|
|
|
272
324
|
messages.forEach(message => {
|
|
273
325
|
const { body, attachments } = message
|
|
326
|
+
const createdAt = message.created_at || message.createdAt
|
|
274
327
|
|
|
275
328
|
const div = document.createElement('div')
|
|
276
329
|
div.innerHTML = body
|
|
@@ -302,6 +355,7 @@ export default class extends Controller {
|
|
|
302
355
|
}
|
|
303
356
|
|
|
304
357
|
element.setAttribute('data-body', body)
|
|
358
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
|
|
305
359
|
this.messagesContainerTarget.prepend(element)
|
|
306
360
|
})
|
|
307
361
|
|
|
@@ -325,11 +379,29 @@ export default class extends Controller {
|
|
|
325
379
|
}
|
|
326
380
|
|
|
327
381
|
closePopover() {
|
|
328
|
-
this.
|
|
382
|
+
this.clearPopoverOpenAnimation()
|
|
383
|
+
this.popoverTarget.classList.remove(...this.fadeOutClasses)
|
|
384
|
+
this.openValue = false
|
|
385
|
+
}
|
|
329
386
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
387
|
+
preparePopoverOpenAnimation() {
|
|
388
|
+
this.clearPopoverOpenAnimation()
|
|
389
|
+
this.popoverTarget.classList.remove(...this.fadeOutClasses)
|
|
390
|
+
this.popoverTarget.classList.add('hellotext--webchat-popover-opening')
|
|
391
|
+
|
|
392
|
+
this.popoverOpenAnimationTimeout = setTimeout(() => {
|
|
393
|
+
this.popoverTarget.classList.remove('hellotext--webchat-popover-opening')
|
|
394
|
+
this.popoverOpenAnimationTimeout = null
|
|
395
|
+
}, POPOVER_ANIMATION_DURATION)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
clearPopoverOpenAnimation() {
|
|
399
|
+
if (this.popoverOpenAnimationTimeout) {
|
|
400
|
+
clearTimeout(this.popoverOpenAnimationTimeout)
|
|
401
|
+
this.popoverOpenAnimationTimeout = null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.popoverTarget?.classList.remove('hellotext--webchat-popover-opening')
|
|
333
405
|
}
|
|
334
406
|
|
|
335
407
|
onPopoverOpened() {
|
|
@@ -369,6 +441,7 @@ export default class extends Controller {
|
|
|
369
441
|
}
|
|
370
442
|
|
|
371
443
|
onPopoverClosed() {
|
|
444
|
+
this.clearPopoverOpenAnimation()
|
|
372
445
|
Hellotext.eventEmitter.dispatch('webchat:closed')
|
|
373
446
|
localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'closed')
|
|
374
447
|
}
|
|
@@ -399,6 +472,7 @@ export default class extends Controller {
|
|
|
399
472
|
|
|
400
473
|
onMessageReceived(message) {
|
|
401
474
|
const { id, body, attachments, teaser } = message
|
|
475
|
+
const createdAt = message.created_at || message.createdAt
|
|
402
476
|
|
|
403
477
|
if (!this.claimMessageId(id)) return
|
|
404
478
|
|
|
@@ -418,6 +492,7 @@ export default class extends Controller {
|
|
|
418
492
|
|
|
419
493
|
element.setAttribute('data-id', id)
|
|
420
494
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
495
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
|
|
421
496
|
|
|
422
497
|
if (attachments) {
|
|
423
498
|
attachments.forEach(attachmentUrl => {
|
|
@@ -487,6 +562,7 @@ export default class extends Controller {
|
|
|
487
562
|
|
|
488
563
|
element.setAttribute('data-id', message.id)
|
|
489
564
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
565
|
+
this.localizeMessageTimestamps(element)
|
|
490
566
|
|
|
491
567
|
this.clearTypingIndicator()
|
|
492
568
|
this.messagesContainerTarget.appendChild(element)
|
|
@@ -527,16 +603,16 @@ export default class extends Controller {
|
|
|
527
603
|
const formData = new FormData()
|
|
528
604
|
|
|
529
605
|
formData.append('message[body]', body)
|
|
530
|
-
formData.append('message[replied_to]', id)
|
|
531
|
-
formData.append('message[product]', product)
|
|
532
|
-
formData.append('message[button]', buttonId)
|
|
606
|
+
if (id) formData.append('message[replied_to]', id)
|
|
607
|
+
if (product) formData.append('message[product]', product)
|
|
608
|
+
if (buttonId) formData.append('message[button]', buttonId)
|
|
533
609
|
|
|
534
610
|
formData.append('session', Hellotext.session)
|
|
535
611
|
formData.append('locale', Locale.toString())
|
|
536
612
|
this.appendOpeningSequenceMessageIds(formData)
|
|
537
613
|
|
|
538
614
|
const element = this.buildMessageElement()
|
|
539
|
-
const attachment = cardElement
|
|
615
|
+
const attachment = cardElement?.querySelector('img')?.cloneNode(true)
|
|
540
616
|
|
|
541
617
|
element.querySelector('[data-body]').innerText = body
|
|
542
618
|
|
|
@@ -566,17 +642,13 @@ export default class extends Controller {
|
|
|
566
642
|
// Clear the optimistic typing indicator on failure
|
|
567
643
|
clearTimeout(this.optimisticTypingTimeout)
|
|
568
644
|
|
|
569
|
-
this.
|
|
570
|
-
type: 'message:failed',
|
|
571
|
-
id: element.id,
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
return element.classList.add('failed')
|
|
645
|
+
return this.markMessageFailedFromResponse(response, element)
|
|
575
646
|
}
|
|
576
647
|
|
|
577
648
|
const data = await response.json()
|
|
578
649
|
|
|
579
650
|
this.dispatch('set:id', { target: element, detail: data.id })
|
|
651
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
|
|
580
652
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
581
653
|
|
|
582
654
|
const message = {
|
|
@@ -645,16 +717,12 @@ export default class extends Controller {
|
|
|
645
717
|
if (response.failed) {
|
|
646
718
|
clearTimeout(this.optimisticTypingTimeout)
|
|
647
719
|
|
|
648
|
-
this.
|
|
649
|
-
type: 'message:failed',
|
|
650
|
-
id: element.id,
|
|
651
|
-
})
|
|
652
|
-
|
|
653
|
-
return element.classList.add('failed')
|
|
720
|
+
return this.markMessageFailedFromResponse(response, element)
|
|
654
721
|
}
|
|
655
722
|
|
|
656
723
|
const data = await response.json()
|
|
657
724
|
element.setAttribute('data-id', data.id)
|
|
725
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
|
|
658
726
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
659
727
|
|
|
660
728
|
Hellotext.eventEmitter.dispatch('webchat:message:sent', {
|
|
@@ -768,17 +836,13 @@ export default class extends Controller {
|
|
|
768
836
|
// Clear the optimistic typing indicator on failure
|
|
769
837
|
clearTimeout(this.optimisticTypingTimeout)
|
|
770
838
|
|
|
771
|
-
this.
|
|
772
|
-
type: 'message:failed',
|
|
773
|
-
id: element.id,
|
|
774
|
-
})
|
|
775
|
-
|
|
776
|
-
return element.classList.add('failed')
|
|
839
|
+
return this.markMessageFailedFromResponse(response, element)
|
|
777
840
|
}
|
|
778
841
|
|
|
779
842
|
const data = await response.json()
|
|
780
843
|
element.setAttribute('data-id', data.id)
|
|
781
844
|
message.id = data.id
|
|
845
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
|
|
782
846
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
783
847
|
|
|
784
848
|
Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
|
|
@@ -807,9 +871,173 @@ export default class extends Controller {
|
|
|
807
871
|
element.setAttribute('data-controller', 'hellotext--message')
|
|
808
872
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
809
873
|
|
|
874
|
+
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), new Date())
|
|
810
875
|
return element
|
|
811
876
|
}
|
|
812
877
|
|
|
878
|
+
focusCompose(event) {
|
|
879
|
+
const { target } = event
|
|
880
|
+
const ignoredSelector = [
|
|
881
|
+
'button',
|
|
882
|
+
'a',
|
|
883
|
+
'input',
|
|
884
|
+
'textarea',
|
|
885
|
+
'select',
|
|
886
|
+
'label',
|
|
887
|
+
'[role="button"]',
|
|
888
|
+
'em-emoji-picker',
|
|
889
|
+
'[data-hellotext--webchat--emoji-target~="popover"]',
|
|
890
|
+
'[data-controller~="hellotext--webchat--emoji"]',
|
|
891
|
+
].join(', ')
|
|
892
|
+
|
|
893
|
+
if (!this.hasInputTarget || target.closest(ignoredSelector)) return
|
|
894
|
+
|
|
895
|
+
event.preventDefault()
|
|
896
|
+
this.inputTarget.focus()
|
|
897
|
+
|
|
898
|
+
if (typeof this.inputTarget.selectionStart === 'number') {
|
|
899
|
+
const position = this.inputTarget.value.length
|
|
900
|
+
this.inputTarget.setSelectionRange(position, position)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
closePopoverFromHeader(event) {
|
|
905
|
+
const { target } = event
|
|
906
|
+
|
|
907
|
+
if (target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')) return
|
|
908
|
+
|
|
909
|
+
event.preventDefault()
|
|
910
|
+
this.closePopover()
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
closePopoverOnEscape(event) {
|
|
914
|
+
if (event.key !== 'Escape' || !this.openValue) return
|
|
915
|
+
|
|
916
|
+
event.preventDefault()
|
|
917
|
+
event.stopPropagation()
|
|
918
|
+
this.closePopover()
|
|
919
|
+
|
|
920
|
+
this.triggerTarget?.focus?.()
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async markMessageFailedFromResponse(response, element) {
|
|
924
|
+
const reason = await this.messageFailureReason(response)
|
|
925
|
+
|
|
926
|
+
this.markMessageFailed(element, reason)
|
|
927
|
+
this.broadcastChannel.postMessage({
|
|
928
|
+
type: 'message:failed',
|
|
929
|
+
id: element.id,
|
|
930
|
+
reason,
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
markMessageFailed(element, reason) {
|
|
935
|
+
if (!element) return
|
|
936
|
+
|
|
937
|
+
element.classList.add('failed')
|
|
938
|
+
|
|
939
|
+
if (!reason) return
|
|
940
|
+
|
|
941
|
+
const timestamp = element.querySelector('[data-message-timestamp]')
|
|
942
|
+
|
|
943
|
+
if (timestamp) {
|
|
944
|
+
timestamp.textContent = reason
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
localizeMessageTimestamps(root = this.element) {
|
|
949
|
+
if (!root) return
|
|
950
|
+
|
|
951
|
+
const timestamps = root.matches?.('time[datetime][data-message-timestamp]')
|
|
952
|
+
? [root]
|
|
953
|
+
: Array.from(root.querySelectorAll?.('time[datetime][data-message-timestamp]') || [])
|
|
954
|
+
|
|
955
|
+
timestamps.forEach(timestamp => this.localizeMessageTimestamp(timestamp))
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
localizeMessageTimestamp(timestamp, value = timestamp?.getAttribute('datetime')) {
|
|
959
|
+
if (!timestamp || !value) return
|
|
960
|
+
|
|
961
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
962
|
+
|
|
963
|
+
if (Number.isNaN(date.getTime())) return
|
|
964
|
+
|
|
965
|
+
timestamp.setAttribute('datetime', date.toISOString())
|
|
966
|
+
timestamp.textContent = this.formatMessageTimestamp(date)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
formatMessageTimestamp(date) {
|
|
970
|
+
return this.constructor.messageTimestampFormatterFor(Locale.toString()).format(date)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
static messageTimestampFormatterFor(locale) {
|
|
974
|
+
const key = locale || 'default'
|
|
975
|
+
|
|
976
|
+
if (!this.messageTimestampFormatters[key]) {
|
|
977
|
+
this.messageTimestampFormatters[key] = this.buildMessageTimestampFormatter(locale)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return this.messageTimestampFormatters[key]
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
static buildMessageTimestampFormatter(locale) {
|
|
984
|
+
try {
|
|
985
|
+
return new Intl.DateTimeFormat(locale || undefined, MESSAGE_TIMESTAMP_FORMAT_OPTIONS)
|
|
986
|
+
} catch (_) {
|
|
987
|
+
return new Intl.DateTimeFormat(undefined, MESSAGE_TIMESTAMP_FORMAT_OPTIONS)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async messageFailureReason(response) {
|
|
992
|
+
const nativeResponse = response?.data || response?.response
|
|
993
|
+
const fallback = nativeResponse?.statusText || 'Message failed'
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
const jsonResponse = nativeResponse?.clone ? nativeResponse.clone() : nativeResponse
|
|
997
|
+
const payload = await jsonResponse?.json?.()
|
|
998
|
+
const reason = this.messageFailureReasonFromPayload(payload)
|
|
999
|
+
|
|
1000
|
+
if (reason) return reason
|
|
1001
|
+
} catch (_) {
|
|
1002
|
+
// Fall through to text parsing/fallback. Some stores strip content-type or
|
|
1003
|
+
// return non-JSON failures, so the timestamp still needs a useful reason.
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
try {
|
|
1007
|
+
const textResponse = nativeResponse?.clone ? nativeResponse.clone() : nativeResponse
|
|
1008
|
+
const text = await textResponse?.text?.()
|
|
1009
|
+
|
|
1010
|
+
return this.messageFailureReasonFromText(text) || fallback
|
|
1011
|
+
} catch (_) {
|
|
1012
|
+
return fallback
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
messageFailureReasonFromText(text) {
|
|
1017
|
+
if (typeof text !== 'string') return null
|
|
1018
|
+
|
|
1019
|
+
const value = text.trim()
|
|
1020
|
+
if (!value || value.startsWith('<')) return null
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
return this.messageFailureReasonFromPayload(JSON.parse(value)) || value
|
|
1024
|
+
} catch (_) {
|
|
1025
|
+
return value
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
messageFailureReasonFromPayload(payload) {
|
|
1030
|
+
if (!payload) return null
|
|
1031
|
+
|
|
1032
|
+
return [
|
|
1033
|
+
payload.error?.message,
|
|
1034
|
+
payload.message,
|
|
1035
|
+
payload.errors?.message,
|
|
1036
|
+
payload.errors?.[0]?.message,
|
|
1037
|
+
payload.errors?.[0]?.description,
|
|
1038
|
+
].find(reason => typeof reason === 'string' && reason.trim().length > 0)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
813
1041
|
messageAttachmentsContainer(element) {
|
|
814
1042
|
return element.querySelector('[data-attachments-container], [data-attachment-container]')
|
|
815
1043
|
}
|
|
@@ -32,6 +32,11 @@ class Configuration {
|
|
|
32
32
|
*/
|
|
33
33
|
static assign(props) {
|
|
34
34
|
if (props) {
|
|
35
|
+
const shouldInferActionCableUrl = (
|
|
36
|
+
Object.prototype.hasOwnProperty.call(props, 'apiRoot') &&
|
|
37
|
+
!Object.prototype.hasOwnProperty.call(props, 'actionCableUrl')
|
|
38
|
+
)
|
|
39
|
+
|
|
35
40
|
Object.entries(props).forEach(([key, value]) => {
|
|
36
41
|
if (key === 'forms') {
|
|
37
42
|
this.forms = Forms.assign(value)
|
|
@@ -41,6 +46,10 @@ class Configuration {
|
|
|
41
46
|
this[key] = value
|
|
42
47
|
}
|
|
43
48
|
})
|
|
49
|
+
|
|
50
|
+
if (shouldInferActionCableUrl) {
|
|
51
|
+
this.actionCableUrl = this.actionCableUrlForApiRoot(this.apiRoot)
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
return this
|
|
@@ -57,6 +66,22 @@ class Configuration {
|
|
|
57
66
|
static endpoint(path) {
|
|
58
67
|
return `${this.apiRoot}/${path}`
|
|
59
68
|
}
|
|
69
|
+
|
|
70
|
+
static actionCableUrlForApiRoot(apiRoot) {
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(apiRoot)
|
|
73
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
74
|
+
|
|
75
|
+
url.protocol = protocol
|
|
76
|
+
url.pathname = '/cable'
|
|
77
|
+
url.search = ''
|
|
78
|
+
url.hash = ''
|
|
79
|
+
|
|
80
|
+
return url.toString()
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return this.actionCableUrl
|
|
83
|
+
}
|
|
84
|
+
}
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
export { Configuration }
|
package/src/models/business.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import locales from '../locales'
|
|
2
2
|
import BusinessesAPI from '../api/businesses'
|
|
3
3
|
|
|
4
|
+
const stylesheetAttribute = 'data-hellotext-stylesheet'
|
|
5
|
+
const stylesheetLoadTimeout = 10000
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* @typedef {Object} BusinessCountry
|
|
6
9
|
* @property {String} [code] - ISO country code configured for the business.
|
|
@@ -38,6 +41,8 @@ class Business {
|
|
|
38
41
|
constructor(id) {
|
|
39
42
|
this.id = id
|
|
40
43
|
this.data = null
|
|
44
|
+
this.stylesheet = null
|
|
45
|
+
this.stylesheetLoaded = Promise.resolve(false)
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -81,15 +86,91 @@ class Business {
|
|
|
81
86
|
setData(data) {
|
|
82
87
|
this.data = data
|
|
83
88
|
|
|
84
|
-
if (typeof document !== 'undefined') {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
if (typeof document !== 'undefined' && data.style_url) {
|
|
90
|
+
this.stylesheet = this.constructor.ensureStylesheet(data.style_url)
|
|
91
|
+
this.stylesheetLoaded = this.constructor.waitForStylesheet(this.stylesheet)
|
|
92
|
+
} else {
|
|
93
|
+
this.stylesheet = null
|
|
94
|
+
this.stylesheetLoaded = Promise.resolve(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static get stylesheetSelector() {
|
|
99
|
+
return `link[rel="stylesheet"][${stylesheetAttribute}]`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static ensureStylesheet(styleUrl) {
|
|
103
|
+
const href = this.normalizedStylesheetUrl(styleUrl)
|
|
104
|
+
const existingLink = this.stylesheetLinks.find(link => link.href === href)
|
|
105
|
+
|
|
106
|
+
if (existingLink) {
|
|
107
|
+
existingLink.setAttribute(stylesheetAttribute, 'true')
|
|
108
|
+
return existingLink
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const linkTag = document.createElement('link')
|
|
112
|
+
linkTag.rel = 'stylesheet'
|
|
113
|
+
linkTag.href = styleUrl
|
|
114
|
+
linkTag.setAttribute(stylesheetAttribute, 'true')
|
|
115
|
+
this.waitForStylesheet(linkTag)
|
|
116
|
+
|
|
117
|
+
document.head.append(linkTag)
|
|
118
|
+
|
|
119
|
+
return linkTag
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static get stylesheetLinks() {
|
|
123
|
+
if (typeof document === 'undefined') return []
|
|
124
|
+
|
|
125
|
+
return Array.from(document.querySelectorAll(this.stylesheetSelector))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static get latestStylesheet() {
|
|
129
|
+
return this.stylesheetLinks[this.stylesheetLinks.length - 1]
|
|
130
|
+
}
|
|
88
131
|
|
|
89
|
-
|
|
132
|
+
static normalizedStylesheetUrl(styleUrl) {
|
|
133
|
+
try {
|
|
134
|
+
return new URL(styleUrl, document.baseURI).href
|
|
135
|
+
} catch (_error) {
|
|
136
|
+
return styleUrl
|
|
90
137
|
}
|
|
91
138
|
}
|
|
92
139
|
|
|
140
|
+
static waitForStylesheet(linkTag) {
|
|
141
|
+
if (!linkTag) return Promise.resolve(false)
|
|
142
|
+
if (this.stylesheetIsLoaded(linkTag)) return Promise.resolve(true)
|
|
143
|
+
if (linkTag.dataset.hellotextStylesheetLoaded === 'false') return Promise.resolve(false)
|
|
144
|
+
if (linkTag._hellotextStylesheetLoaded) return linkTag._hellotextStylesheetLoaded
|
|
145
|
+
|
|
146
|
+
linkTag._hellotextStylesheetLoaded = new Promise(resolve => {
|
|
147
|
+
let timeout
|
|
148
|
+
|
|
149
|
+
const finish = loaded => {
|
|
150
|
+
clearTimeout(timeout)
|
|
151
|
+
linkTag.removeEventListener('load', handleLoad)
|
|
152
|
+
linkTag.removeEventListener('error', handleError)
|
|
153
|
+
linkTag.dataset.hellotextStylesheetLoaded = loaded ? 'true' : 'false'
|
|
154
|
+
resolve(loaded)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const handleLoad = () => finish(this.stylesheetIsLoaded(linkTag))
|
|
158
|
+
const handleError = () => finish(false)
|
|
159
|
+
|
|
160
|
+
linkTag.addEventListener('load', handleLoad)
|
|
161
|
+
linkTag.addEventListener('error', handleError)
|
|
162
|
+
|
|
163
|
+
timeout = setTimeout(() => finish(this.stylesheetIsLoaded(linkTag)), stylesheetLoadTimeout)
|
|
164
|
+
if (timeout.unref) timeout.unref()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return linkTag._hellotextStylesheetLoaded
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static stylesheetIsLoaded(linkTag) {
|
|
171
|
+
return linkTag.dataset.hellotextStylesheetLoaded === 'true' || !!linkTag.sheet
|
|
172
|
+
}
|
|
173
|
+
|
|
93
174
|
get subscription() {
|
|
94
175
|
return this.data.subscription
|
|
95
176
|
}
|
package/src/models/webchat.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
import { Configuration } from '../core'
|
|
2
2
|
|
|
3
3
|
import API from '../api'
|
|
4
|
+
import { Business } from './business'
|
|
4
5
|
|
|
5
6
|
class Webchat {
|
|
6
7
|
static async load(id) {
|
|
7
|
-
|
|
8
|
+
const webchat = new Webchat({
|
|
8
9
|
id,
|
|
9
10
|
html: await API.webchats.get(id),
|
|
10
11
|
})
|
|
12
|
+
|
|
13
|
+
webchat.rendered = webchat.render()
|
|
14
|
+
|
|
15
|
+
return webchat
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
constructor(data) {
|
|
14
19
|
this.data = data
|
|
15
|
-
this.
|
|
20
|
+
this.mounted = false
|
|
21
|
+
this.rendered = Promise.resolve(false)
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
render() {
|
|
24
|
+
async render() {
|
|
19
25
|
this.applyBehaviourOverride()
|
|
26
|
+
|
|
27
|
+
if (!await this.stylesheetLoaded) {
|
|
28
|
+
console.warn('Hellotext webchat was not mounted because its stylesheet failed to load.')
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
this.containerToAppendTo.appendChild(this.data.html)
|
|
33
|
+
this.mounted = true
|
|
34
|
+
|
|
35
|
+
return true
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
applyBehaviourOverride() {
|
|
@@ -50,6 +65,10 @@ class Webchat {
|
|
|
50
65
|
get containerToAppendTo() {
|
|
51
66
|
return document.querySelector(Configuration.webchat.container)
|
|
52
67
|
}
|
|
68
|
+
|
|
69
|
+
get stylesheetLoaded() {
|
|
70
|
+
return Business.waitForStylesheet(Business.latestStylesheet)
|
|
71
|
+
}
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
export { Webchat }
|