@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.
@@ -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}`)?.classList.add('failed')
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.popoverTarget.classList.add(...this.fadeOutClasses)
382
+ this.clearPopoverOpenAnimation()
383
+ this.popoverTarget.classList.remove(...this.fadeOutClasses)
384
+ this.openValue = false
385
+ }
329
386
 
330
- setTimeout(() => {
331
- this.openValue = false
332
- }, 250)
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.querySelector('img')?.cloneNode(true)
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.broadcastChannel.postMessage({
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.broadcastChannel.postMessage({
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.broadcastChannel.postMessage({
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 }
@@ -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
- const linkTag = document.createElement('link')
86
- linkTag.rel = 'stylesheet'
87
- linkTag.href = data.style_url
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
- document.head.append(linkTag)
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
  }
@@ -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
- return new Webchat({
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.render()
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 }