@hellotext/hellotext 2.4.2 → 2.4.3

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,15 @@ 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
+
16
22
  export default class extends Controller {
23
+ static messageTimestampFormatters = {}
24
+
17
25
  static values = {
18
26
  id: String,
19
27
  conversationId: String,
@@ -83,6 +91,7 @@ export default class extends Controller {
83
91
  this.onScroll = this.onScroll.bind(this)
84
92
 
85
93
  this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this)
94
+ this.closePopoverOnEscape = this.closePopoverOnEscape.bind(this)
86
95
  this.broadcastChannel = new BroadcastChannel(`hellotext--webchat--${this.idValue}`)
87
96
 
88
97
  super.initialize()
@@ -106,6 +115,7 @@ export default class extends Controller {
106
115
 
107
116
  this.setupTeaser()
108
117
  this.setupOpeningSequence()
118
+ this.localizeMessageTimestamps()
109
119
 
110
120
  this.webChatChannel.onMessage(this.onMessageReceived)
111
121
  this.webChatChannel.onTypingStart(this.onTypingStart)
@@ -120,6 +130,7 @@ export default class extends Controller {
120
130
 
121
131
  Hellotext.eventEmitter.dispatch('webchat:mounted')
122
132
  this.broadcastChannel.addEventListener('message', this.onOutboundMessageSent)
133
+ window.addEventListener('keydown', this.closePopoverOnEscape, true)
123
134
  this.scheduleBehaviourOpen()
124
135
 
125
136
  super.connect()
@@ -127,11 +138,13 @@ export default class extends Controller {
127
138
 
128
139
  disconnect() {
129
140
  this.cancelBehaviourOpen()
141
+ this.clearPopoverOpenAnimation()
130
142
  this.teardownTeaser()
131
143
  this.teardownOpeningSequence()
132
144
 
133
145
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
134
146
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
147
+ window.removeEventListener('keydown', this.closePopoverOnEscape, true)
135
148
 
136
149
  // Clean up typing indicator timeouts
137
150
  this.clearTypingIndicator()
@@ -228,6 +241,7 @@ export default class extends Controller {
228
241
  'message:sent': data => {
229
242
  const element = new DOMParser().parseFromString(data.element, 'text/html').body
230
243
  .firstElementChild
244
+ this.localizeMessageTimestamps(element)
231
245
 
232
246
  // Insert message before typing indicator if one exists
233
247
  if (this.typingIndicatorVisible && this.hasTypingIndicatorTarget) {
@@ -239,7 +253,9 @@ export default class extends Controller {
239
253
  element.scrollIntoView({ behavior: 'instant' })
240
254
  },
241
255
  'message:failed': data => {
242
- this.messagesContainerTarget.querySelector(`#${data.id}`)?.classList.add('failed')
256
+ const element = this.messagesContainerTarget.querySelector(`#${data.id}`)
257
+
258
+ this.markMessageFailed(element, data.reason)
243
259
  },
244
260
  }
245
261
 
@@ -271,6 +287,7 @@ export default class extends Controller {
271
287
 
272
288
  messages.forEach(message => {
273
289
  const { body, attachments } = message
290
+ const createdAt = message.created_at || message.createdAt
274
291
 
275
292
  const div = document.createElement('div')
276
293
  div.innerHTML = body
@@ -302,6 +319,7 @@ export default class extends Controller {
302
319
  }
303
320
 
304
321
  element.setAttribute('data-body', body)
322
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
305
323
  this.messagesContainerTarget.prepend(element)
306
324
  })
307
325
 
@@ -325,11 +343,29 @@ export default class extends Controller {
325
343
  }
326
344
 
327
345
  closePopover() {
328
- this.popoverTarget.classList.add(...this.fadeOutClasses)
346
+ this.clearPopoverOpenAnimation()
347
+ this.popoverTarget.classList.remove(...this.fadeOutClasses)
348
+ this.openValue = false
349
+ }
329
350
 
330
- setTimeout(() => {
331
- this.openValue = false
332
- }, 250)
351
+ preparePopoverOpenAnimation() {
352
+ this.clearPopoverOpenAnimation()
353
+ this.popoverTarget.classList.remove(...this.fadeOutClasses)
354
+ this.popoverTarget.classList.add('hellotext--webchat-popover-opening')
355
+
356
+ this.popoverOpenAnimationTimeout = setTimeout(() => {
357
+ this.popoverTarget.classList.remove('hellotext--webchat-popover-opening')
358
+ this.popoverOpenAnimationTimeout = null
359
+ }, POPOVER_ANIMATION_DURATION)
360
+ }
361
+
362
+ clearPopoverOpenAnimation() {
363
+ if (this.popoverOpenAnimationTimeout) {
364
+ clearTimeout(this.popoverOpenAnimationTimeout)
365
+ this.popoverOpenAnimationTimeout = null
366
+ }
367
+
368
+ this.popoverTarget?.classList.remove('hellotext--webchat-popover-opening')
333
369
  }
334
370
 
335
371
  onPopoverOpened() {
@@ -369,6 +405,7 @@ export default class extends Controller {
369
405
  }
370
406
 
371
407
  onPopoverClosed() {
408
+ this.clearPopoverOpenAnimation()
372
409
  Hellotext.eventEmitter.dispatch('webchat:closed')
373
410
  localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'closed')
374
411
  }
@@ -399,6 +436,7 @@ export default class extends Controller {
399
436
 
400
437
  onMessageReceived(message) {
401
438
  const { id, body, attachments, teaser } = message
439
+ const createdAt = message.created_at || message.createdAt
402
440
 
403
441
  if (!this.claimMessageId(id)) return
404
442
 
@@ -418,6 +456,7 @@ export default class extends Controller {
418
456
 
419
457
  element.setAttribute('data-id', id)
420
458
  element.setAttribute('data-hellotext--webchat-target', 'message')
459
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
421
460
 
422
461
  if (attachments) {
423
462
  attachments.forEach(attachmentUrl => {
@@ -487,6 +526,7 @@ export default class extends Controller {
487
526
 
488
527
  element.setAttribute('data-id', message.id)
489
528
  element.setAttribute('data-hellotext--webchat-target', 'message')
529
+ this.localizeMessageTimestamps(element)
490
530
 
491
531
  this.clearTypingIndicator()
492
532
  this.messagesContainerTarget.appendChild(element)
@@ -527,16 +567,16 @@ export default class extends Controller {
527
567
  const formData = new FormData()
528
568
 
529
569
  formData.append('message[body]', body)
530
- formData.append('message[replied_to]', id)
531
- formData.append('message[product]', product)
532
- formData.append('message[button]', buttonId)
570
+ if (id) formData.append('message[replied_to]', id)
571
+ if (product) formData.append('message[product]', product)
572
+ if (buttonId) formData.append('message[button]', buttonId)
533
573
 
534
574
  formData.append('session', Hellotext.session)
535
575
  formData.append('locale', Locale.toString())
536
576
  this.appendOpeningSequenceMessageIds(formData)
537
577
 
538
578
  const element = this.buildMessageElement()
539
- const attachment = cardElement.querySelector('img')?.cloneNode(true)
579
+ const attachment = cardElement?.querySelector('img')?.cloneNode(true)
540
580
 
541
581
  element.querySelector('[data-body]').innerText = body
542
582
 
@@ -566,17 +606,13 @@ export default class extends Controller {
566
606
  // Clear the optimistic typing indicator on failure
567
607
  clearTimeout(this.optimisticTypingTimeout)
568
608
 
569
- this.broadcastChannel.postMessage({
570
- type: 'message:failed',
571
- id: element.id,
572
- })
573
-
574
- return element.classList.add('failed')
609
+ return this.markMessageFailedFromResponse(response, element)
575
610
  }
576
611
 
577
612
  const data = await response.json()
578
613
 
579
614
  this.dispatch('set:id', { target: element, detail: data.id })
615
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
580
616
  this.clearRevealedOpeningSequenceMessageIds()
581
617
 
582
618
  const message = {
@@ -645,16 +681,12 @@ export default class extends Controller {
645
681
  if (response.failed) {
646
682
  clearTimeout(this.optimisticTypingTimeout)
647
683
 
648
- this.broadcastChannel.postMessage({
649
- type: 'message:failed',
650
- id: element.id,
651
- })
652
-
653
- return element.classList.add('failed')
684
+ return this.markMessageFailedFromResponse(response, element)
654
685
  }
655
686
 
656
687
  const data = await response.json()
657
688
  element.setAttribute('data-id', data.id)
689
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
658
690
  this.clearRevealedOpeningSequenceMessageIds()
659
691
 
660
692
  Hellotext.eventEmitter.dispatch('webchat:message:sent', {
@@ -768,17 +800,13 @@ export default class extends Controller {
768
800
  // Clear the optimistic typing indicator on failure
769
801
  clearTimeout(this.optimisticTypingTimeout)
770
802
 
771
- this.broadcastChannel.postMessage({
772
- type: 'message:failed',
773
- id: element.id,
774
- })
775
-
776
- return element.classList.add('failed')
803
+ return this.markMessageFailedFromResponse(response, element)
777
804
  }
778
805
 
779
806
  const data = await response.json()
780
807
  element.setAttribute('data-id', data.id)
781
808
  message.id = data.id
809
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
782
810
  this.clearRevealedOpeningSequenceMessageIds()
783
811
 
784
812
  Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
@@ -807,9 +835,173 @@ export default class extends Controller {
807
835
  element.setAttribute('data-controller', 'hellotext--message')
808
836
  element.setAttribute('data-hellotext--webchat-target', 'message')
809
837
 
838
+ this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), new Date())
810
839
  return element
811
840
  }
812
841
 
842
+ focusCompose(event) {
843
+ const { target } = event
844
+ const ignoredSelector = [
845
+ 'button',
846
+ 'a',
847
+ 'input',
848
+ 'textarea',
849
+ 'select',
850
+ 'label',
851
+ '[role="button"]',
852
+ 'em-emoji-picker',
853
+ '[data-hellotext--webchat--emoji-target~="popover"]',
854
+ '[data-controller~="hellotext--webchat--emoji"]',
855
+ ].join(', ')
856
+
857
+ if (!this.hasInputTarget || target.closest(ignoredSelector)) return
858
+
859
+ event.preventDefault()
860
+ this.inputTarget.focus()
861
+
862
+ if (typeof this.inputTarget.selectionStart === 'number') {
863
+ const position = this.inputTarget.value.length
864
+ this.inputTarget.setSelectionRange(position, position)
865
+ }
866
+ }
867
+
868
+ closePopoverFromHeader(event) {
869
+ const { target } = event
870
+
871
+ if (target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')) return
872
+
873
+ event.preventDefault()
874
+ this.closePopover()
875
+ }
876
+
877
+ closePopoverOnEscape(event) {
878
+ if (event.key !== 'Escape' || !this.openValue) return
879
+
880
+ event.preventDefault()
881
+ event.stopPropagation()
882
+ this.closePopover()
883
+
884
+ this.triggerTarget?.focus?.()
885
+ }
886
+
887
+ async markMessageFailedFromResponse(response, element) {
888
+ const reason = await this.messageFailureReason(response)
889
+
890
+ this.markMessageFailed(element, reason)
891
+ this.broadcastChannel.postMessage({
892
+ type: 'message:failed',
893
+ id: element.id,
894
+ reason,
895
+ })
896
+ }
897
+
898
+ markMessageFailed(element, reason) {
899
+ if (!element) return
900
+
901
+ element.classList.add('failed')
902
+
903
+ if (!reason) return
904
+
905
+ const timestamp = element.querySelector('[data-message-timestamp]')
906
+
907
+ if (timestamp) {
908
+ timestamp.textContent = reason
909
+ }
910
+ }
911
+
912
+ localizeMessageTimestamps(root = this.element) {
913
+ if (!root) return
914
+
915
+ const timestamps = root.matches?.('time[datetime][data-message-timestamp]')
916
+ ? [root]
917
+ : Array.from(root.querySelectorAll?.('time[datetime][data-message-timestamp]') || [])
918
+
919
+ timestamps.forEach(timestamp => this.localizeMessageTimestamp(timestamp))
920
+ }
921
+
922
+ localizeMessageTimestamp(timestamp, value = timestamp?.getAttribute('datetime')) {
923
+ if (!timestamp || !value) return
924
+
925
+ const date = value instanceof Date ? value : new Date(value)
926
+
927
+ if (Number.isNaN(date.getTime())) return
928
+
929
+ timestamp.setAttribute('datetime', date.toISOString())
930
+ timestamp.textContent = this.formatMessageTimestamp(date)
931
+ }
932
+
933
+ formatMessageTimestamp(date) {
934
+ return this.constructor.messageTimestampFormatterFor(Locale.toString()).format(date)
935
+ }
936
+
937
+ static messageTimestampFormatterFor(locale) {
938
+ const key = locale || 'default'
939
+
940
+ if (!this.messageTimestampFormatters[key]) {
941
+ this.messageTimestampFormatters[key] = this.buildMessageTimestampFormatter(locale)
942
+ }
943
+
944
+ return this.messageTimestampFormatters[key]
945
+ }
946
+
947
+ static buildMessageTimestampFormatter(locale) {
948
+ try {
949
+ return new Intl.DateTimeFormat(locale || undefined, MESSAGE_TIMESTAMP_FORMAT_OPTIONS)
950
+ } catch (_) {
951
+ return new Intl.DateTimeFormat(undefined, MESSAGE_TIMESTAMP_FORMAT_OPTIONS)
952
+ }
953
+ }
954
+
955
+ async messageFailureReason(response) {
956
+ const nativeResponse = response?.data || response?.response
957
+ const fallback = nativeResponse?.statusText || 'Message failed'
958
+
959
+ try {
960
+ const jsonResponse = nativeResponse?.clone ? nativeResponse.clone() : nativeResponse
961
+ const payload = await jsonResponse?.json?.()
962
+ const reason = this.messageFailureReasonFromPayload(payload)
963
+
964
+ if (reason) return reason
965
+ } catch (_) {
966
+ // Fall through to text parsing/fallback. Some stores strip content-type or
967
+ // return non-JSON failures, so the timestamp still needs a useful reason.
968
+ }
969
+
970
+ try {
971
+ const textResponse = nativeResponse?.clone ? nativeResponse.clone() : nativeResponse
972
+ const text = await textResponse?.text?.()
973
+
974
+ return this.messageFailureReasonFromText(text) || fallback
975
+ } catch (_) {
976
+ return fallback
977
+ }
978
+ }
979
+
980
+ messageFailureReasonFromText(text) {
981
+ if (typeof text !== 'string') return null
982
+
983
+ const value = text.trim()
984
+ if (!value || value.startsWith('<')) return null
985
+
986
+ try {
987
+ return this.messageFailureReasonFromPayload(JSON.parse(value)) || value
988
+ } catch (_) {
989
+ return value
990
+ }
991
+ }
992
+
993
+ messageFailureReasonFromPayload(payload) {
994
+ if (!payload) return null
995
+
996
+ return [
997
+ payload.error?.message,
998
+ payload.message,
999
+ payload.errors?.message,
1000
+ payload.errors?.[0]?.message,
1001
+ payload.errors?.[0]?.description,
1002
+ ].find(reason => typeof reason === 'string' && reason.trim().length > 0)
1003
+ }
1004
+
813
1005
  messageAttachmentsContainer(element) {
814
1006
  return element.querySelector('[data-attachments-container], [data-attachment-container]')
815
1007
  }
@@ -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 }