@defra/flood-webchat 0.0.1-alpha.2 → 0.0.1-alpha.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.
@@ -0,0 +1,1073 @@
1
+ import { ChatEvent, ChatSdk, EnvironmentName } from '@nice-devone/nice-cxone-chat-web-sdk'
2
+ import State from './state.js'
3
+ import Skiplink from './skiplink.js'
4
+ import Availability from './availability.js'
5
+ import Panel from './panel.js'
6
+ import Notification from './notification.js'
7
+ import Keyboard from './keyboard.js'
8
+ import Transcript from './transcript.js'
9
+ import Config from './config.js'
10
+ import Utils from './utils.js'
11
+
12
+ /** Class representing flood webchat. */
13
+ class WebChat {
14
+ /**
15
+ * @param {string} id
16
+ * @param {object} options
17
+ * @param {string} options.brandId
18
+ * @param {string} options.channelId
19
+ * @param {string} options.environmentName
20
+ * @param {string} options.availabilityEndpoint
21
+ **/
22
+ constructor (id, options) {
23
+ this.id = id
24
+ this.brandId = options.brandId
25
+ this.channelId = options.channelId
26
+ this.availabilityEndpoint = options.availabilityEndpoint
27
+ this.environment = EnvironmentName[options.environmentName]
28
+
29
+ // Initialise state
30
+ this.state = new State(this._openChat.bind(this), this._closeChat.bind(this))
31
+
32
+ // Initialise availability
33
+ this.availability = new Availability(id, this._openChat.bind(this))
34
+
35
+ // Initialise panel
36
+ this.panel = new Panel()
37
+
38
+ // Initialise skiplink
39
+ this.skiplink = new Skiplink()
40
+
41
+ // Initialise notification
42
+ this.notification = new Notification()
43
+
44
+ // Reinstate html visiblity (avoid refresh flicker)
45
+ if (document.body.classList.contains('wc-hidden')) {
46
+ document.body.classList.remove('wc-hidden')
47
+ document.body.classList.add('wc-body')
48
+ }
49
+
50
+ // Initialise keyboard interface
51
+ const state = this.state
52
+ Keyboard.init(state)
53
+
54
+ // Render availability content
55
+ this.availability.update(state)
56
+
57
+ // Render panel if #webchat exists in url
58
+ if (state.isOpen) {
59
+ const panel = this.panel
60
+ panel.create(state, this._addDomEvents.bind(this))
61
+ panel.update(state)
62
+ }
63
+
64
+ // Conditionally add skiplink
65
+ this.skiplink.toggle(state.view === 'OPEN' || state.view === 'END')
66
+
67
+ // Attach sticky footer scroll event
68
+ document.addEventListener('scroll', e => {
69
+ this.availability.scroll(state)
70
+ })
71
+ this.availability.scroll(state)
72
+
73
+ // Attach custom 'ready' event listener
74
+ this.livechatReady = new CustomEvent('livechatReady', {})
75
+ document.addEventListener('livechatReady', this._handleReadyEvent.bind(this))
76
+
77
+ // Conditiopnally recover thread
78
+ if (state.threadId) {
79
+ this._recoverThread()
80
+ return
81
+ }
82
+
83
+ // Ready to check availability
84
+ document.dispatchEvent(this.livechatReady)
85
+ }
86
+
87
+ async _authorise () {
88
+ console.log('_authorise')
89
+
90
+ // New SDK instance
91
+ const sdk = new ChatSdk({
92
+ brandId: this.brandId,
93
+ channelId: this.channelId,
94
+ environment: this.environment,
95
+ customerId: window.localStorage.getItem('CUSTOMER_ID') || ''
96
+ })
97
+
98
+ // Authorise and set customerId
99
+ const response = await sdk.authorize()
100
+ const customerId = response?.consumerIdentity.idOnExternalPlatform
101
+ window.localStorage.setItem('CUSTOMER_ID', customerId || '')
102
+ this.customerId = customerId
103
+
104
+ // How do we know they have been authorised?
105
+ const state = this.state
106
+ state.isAuthorised = true
107
+
108
+ // Confirm availability from SDK
109
+ const isOnline = response && response.channel.availability.status === 'online'
110
+ state.availability = isOnline ? state.availability : 'UNAVAILABLE'
111
+
112
+ // Add sdk event listeners
113
+
114
+ // v1.3.0
115
+ // sdk.onChatEvent(ChatEvent.CONSUMER_AUTHORIZED, this._handleConsumerAuthorizedEvent.bind(this))
116
+ sdk.onChatEvent(ChatEvent.LIVECHAT_RECOVERED, this._handleLivechatRecoveredEvent.bind(this))
117
+ sdk.onChatEvent(ChatEvent.MESSAGE_CREATED, this._handleMessageCreatedEvent.bind(this))
118
+ sdk.onChatEvent(ChatEvent.AGENT_TYPING_STARTED, this._handleAgentTypingEvent.bind(this))
119
+ sdk.onChatEvent(ChatEvent.AGENT_TYPING_ENDED, this._handleAgentTypingEvent.bind(this))
120
+ sdk.onChatEvent(ChatEvent.MESSAGE_SEEN_BY_END_USER, this._handleMessageSeenByEndUserEvent.bind(this))
121
+ sdk.onChatEvent(ChatEvent.ASSIGNED_AGENT_CHANGED, this._handleAssignedAgentChangedEvent.bind(this))
122
+ sdk.onChatEvent(ChatEvent.CONTACT_CREATED, this._handleContactCreatedEvent.bind(this))
123
+ sdk.onChatEvent(ChatEvent.CONTACT_STATUS_CHANGED, this._handleContactStatusChangedEvent.bind(this))
124
+ sdk.onChatEvent('SetConsumerContactCustomFields', this._handleChatEvent.bind(this))
125
+
126
+ // v1.2.0
127
+ sdk.onChatEvent(ChatEvent.CASE_INBOX_ASSIGNEE_CHANGED, this._handleAssignedAgentChangedEvent.bind(this))
128
+ // sdk.onChatEvent(ChatEvent.CASE_CREATED, this._handleContactCreatedEvent.bind(this))
129
+ // sdk.onChatEvent(ChatEvent.CASE_STATUS_CHANGED, this._handleContactStatusChangedEvent.bind(this))
130
+
131
+ this.sdk = sdk
132
+ }
133
+
134
+ async _recoverThread () {
135
+ console.log('_recoverThread')
136
+
137
+ // Recover thread
138
+ try {
139
+ await this._authorise()
140
+ await this._getThread()
141
+ await this.thread.recover()
142
+ } catch (err) {
143
+ // Address issue with no thread but we still have the thread id
144
+ console.log(err)
145
+ window.localStorage.removeItem('THREAD_ID')
146
+ // Reset view
147
+ this.state.view = 'PRECHAT'
148
+ // Dispatch ready event
149
+ document.dispatchEvent(this.livechatReady)
150
+ }
151
+ }
152
+
153
+ async _getThread () {
154
+ // Get thread
155
+ let threadId = window.localStorage.getItem('THREAD_ID')
156
+ if (!threadId) {
157
+ // Generate id
158
+ const random = Math.floor(Math.random() * 1000).toString()
159
+ const time = (new Date()).getTime()
160
+ threadId = `${time}${random}`
161
+ window.localStorage.setItem('THREAD_ID', threadId)
162
+ }
163
+ const thread = await this.sdk.getThread(threadId)
164
+ this.thread = thread
165
+ this.state.threadId = threadId
166
+ }
167
+
168
+ async _updateAvailability () {
169
+ const state = this.state
170
+ const previousAvailability = state.availability
171
+ state.availability = await Utils.getAvailability(this.availabilityEndpoint)
172
+ const availability = this.availability
173
+ availability.update(state)
174
+ availability.scroll(state)
175
+
176
+ // Alert assistive technology
177
+ const isNewlyAvailable = previousAvailability === 'UNAVAILABLE' && state.availability === 'AVAILABLE'
178
+ if (!state.isOpen && isNewlyAvailable) {
179
+ this._alertAT('Webchat now available')
180
+ }
181
+ }
182
+
183
+ async _startChat () {
184
+ console.log('_startChat')
185
+
186
+ // Check availability
187
+ await this._updateAvailability()
188
+ const state = this.state
189
+ if (state.availability !== 'AVAILABLE') {
190
+ this._unavailable()
191
+ return
192
+ }
193
+
194
+ // Authorise user
195
+ if (!state.isAuthorised) {
196
+ await this._authorise()
197
+ }
198
+
199
+ // ***Bug: Set userName populates last name from string after last space
200
+ this.sdk.getCustomer().setName(state.name)
201
+
202
+ // Start chat
203
+ try {
204
+ await this._getThread()
205
+ this.thread.startChat(state.question || 'Begin conversation')
206
+ } catch (err) {
207
+ console.log(err)
208
+ }
209
+
210
+ // Update panel attributes
211
+ const panel = this.panel
212
+ panel.setAttributes(state)
213
+ }
214
+
215
+ async _confirmEndChat () {
216
+ console.log('_confirmEndChat')
217
+
218
+ // Close thread
219
+ window.localStorage.removeItem('THREAD_ID')
220
+ const state = this.state
221
+ state.threadId = null
222
+
223
+ // Clear name and initial question
224
+ state.messages = []
225
+ state.name = null
226
+ state.question = null
227
+
228
+ // Show feedback view
229
+ this._feedback()
230
+
231
+ // Clear timeout
232
+ this._resetTimeout()
233
+
234
+ // End chat if still open
235
+ const status = state.status
236
+ if (status && status !== 'closed') {
237
+ this.thread.endChat()
238
+ }
239
+ }
240
+
241
+ _addDomEvents () {
242
+ // Button events
243
+ const container = this.panel.container
244
+ container.addEventListener('click', e => {
245
+ if (e.target.hasAttribute('data-wc-back-btn')) {
246
+ e.preventDefault()
247
+ this._closeChat(e)
248
+ }
249
+ if (e.target.hasAttribute('data-wc-hide-btn')) {
250
+ e.preventDefault()
251
+ this._closeChat(e)
252
+ }
253
+ if (e.target.hasAttribute('data-wc-close-btn')) {
254
+ e.preventDefault()
255
+ this._closeChat(e)
256
+ }
257
+ if (e.target.hasAttribute('data-wc-start-btn')) {
258
+ e.preventDefault()
259
+ this._start(e)
260
+ }
261
+ if (e.target.hasAttribute('data-wc-prechat-back-btn')) {
262
+ e.preventDefault()
263
+ this._prechat(e)
264
+ }
265
+ if (e.target.hasAttribute('data-wc-start-back-btn')) {
266
+ e.preventDefault()
267
+ this._start(e)
268
+ }
269
+ if (e.target.hasAttribute('data-wc-request-chat-btn')) {
270
+ e.preventDefault()
271
+ this._validatePrechat(this._startChat.bind(this))
272
+ }
273
+ if (e.target.hasAttribute('data-wc-end-btn')) {
274
+ e.preventDefault()
275
+ this._endChat()
276
+ }
277
+ if (e.target.hasAttribute('data-wc-resume-btn')) {
278
+ e.preventDefault()
279
+ this._resumeChat()
280
+ }
281
+ if (e.target.hasAttribute('data-wc-confirm-end-btn')) {
282
+ e.preventDefault()
283
+ this._confirmEndChat()
284
+ }
285
+ if (e.target.hasAttribute('data-wc-feedback-btn')) {
286
+ e.preventDefault()
287
+ this._feedback()
288
+ }
289
+ if (e.target.hasAttribute('data-wc-submit-feedback-btn')) {
290
+ e.preventDefault()
291
+ this._validateFeedback(this._submitFeedback.bind(this))
292
+ }
293
+ if (e.target.hasAttribute('data-wc-settings-btn')) {
294
+ e.preventDefault()
295
+ this._settings()
296
+ }
297
+ if (e.target.hasAttribute('data-wc-transcript-btn')) {
298
+ e.preventDefault()
299
+ this._download()
300
+ }
301
+ if (e.target.hasAttribute('data-wc-save-settings-btn')) {
302
+ e.preventDefault()
303
+ this._saveSettings(e.target)
304
+ }
305
+ if (e.target.hasAttribute('data-wc-cancel-settings-btn')) {
306
+ e.preventDefault()
307
+ this._resumeChat()
308
+ }
309
+ if (e.target.hasAttribute('data-wc-error-summary-link')) {
310
+ e.preventDefault()
311
+ const id = e.target.href.slice(e.target.href.indexOf('#') + 1)
312
+ document.getElementById(id).focus()
313
+ }
314
+ })
315
+ container.addEventListener('keydown', e => {
316
+ // Send keystroke event
317
+ if (e.target.hasAttribute('data-wc-textbox')) {
318
+ this._handleSendKeystrokeEvent()
319
+ }
320
+ // Prevent scroll chaining
321
+ if (e.target.hasAttribute('data-wc-body')) {
322
+ const m = e.target
323
+ const isBottom = m.scrollTop === (m.scrollHeight - m.offsetHeight)
324
+ const isTop = m.scrollTop <= 0
325
+ if ((e.key === 'ArrowUp' && isTop) || (e.key === 'ArrowDown' && isBottom)) {
326
+ e.preventDefault()
327
+ }
328
+ }
329
+ })
330
+ // Reset timeout event
331
+ container.addEventListener('keyup', e => {
332
+ if (e.target.hasAttribute('data-wc-textbox') && this.timeout) {
333
+ this._resetTimeout()
334
+ }
335
+ })
336
+ // Form submit
337
+ container.addEventListener('submit', e => {
338
+ // Start chat form
339
+ if (e.target.hasAttribute('data-wc-start-form')) {
340
+ e.preventDefault()
341
+ this._validatePrechat(this._startChat.bind(this))
342
+ }
343
+ // Send message form
344
+ if (e.target.hasAttribute('data-wc-message-form')) {
345
+ e.preventDefault()
346
+ this._sendMessage()
347
+ }
348
+ }, true)
349
+ // Close dialog
350
+ container.addEventListener('keyup', e => {
351
+ if (this.state.isOpen && (e.key === 'Escape' || e.key === 'Esc')) {
352
+ this._closeChat(e)
353
+ }
354
+ })
355
+ }
356
+
357
+ _validatePrechat (successCb) {
358
+ const name = document.getElementById('name')
359
+ const question = document.getElementById('question')
360
+ const state = this.state
361
+ state.name = name.value
362
+ state.question = question ? question.value : ''
363
+
364
+ // Validation error
365
+ const isErrorName = state.name.length <= 0
366
+ const isErrorQuestion = state.question.length <= 0 || state.question.length > 500
367
+
368
+ if (isErrorName || isErrorQuestion) {
369
+ const error = {
370
+ nameEmpty: isErrorName,
371
+ questionEmpty: state.question.length <= 0,
372
+ questionExceeded: state.question.length > 500
373
+ }
374
+ const panel = this.panel
375
+ panel.update(state, error)
376
+
377
+ // Move focus to error summary
378
+ const summary = panel.container.querySelector('[data-wc-error-summary]')
379
+ summary.focus()
380
+ return
381
+ }
382
+
383
+ successCb()
384
+ }
385
+
386
+ _validateFeedback (successCb) {
387
+ const panel = this.panel
388
+ const satisfaction = panel.container.querySelector('input[name="satisfaction"]:checked')
389
+
390
+ // Missing satisfaction selection
391
+ if (!satisfaction) {
392
+ const error = {
393
+ satisfactionEmpty: true
394
+ }
395
+
396
+ // Update panel
397
+ const state = this.state
398
+ state.view = 'FEEDBACK'
399
+ panel.update(state, error)
400
+
401
+ // Move focus to error summary
402
+ const summary = panel.container.querySelector('[data-wc-error-summary]')
403
+ summary.focus()
404
+
405
+ // Reset view
406
+ state.view = 'PRECHAT'
407
+
408
+ return
409
+ }
410
+
411
+ // Update SDK
412
+ const thread = this.thread
413
+ const improvements = panel.container.querySelector('textarea[name="improvements"]')
414
+ thread.setCustomField('rating', satisfaction.value)
415
+ thread.setCustomField('comment', improvements.value)
416
+
417
+ successCb()
418
+ }
419
+
420
+ _openChat (e, instigatorId) {
421
+ console.log('_openChat', instigatorId)
422
+
423
+ const state = this.state
424
+
425
+ // Return if already open
426
+ if (state.isOpen) {
427
+ return
428
+ }
429
+
430
+ state.instigatorId = instigatorId
431
+ state.isOpen = true
432
+
433
+ // Reset timeout
434
+ if (this.timeout) {
435
+ this._resetTimeout()
436
+ }
437
+
438
+ // Conditionally push new state to history
439
+ const isBtn = e instanceof window.PointerEvent || e instanceof window.MouseEvent || e instanceof window.KeyboardEvent
440
+ if (isBtn) {
441
+ e.preventDefault()
442
+ state.pushState('PRECHAT')
443
+ }
444
+
445
+ // Create panel
446
+ const panel = this.panel
447
+ if (!panel.container) {
448
+ // Create panel
449
+ panel.create(state, this._addDomEvents.bind(this))
450
+ panel.update(state)
451
+
452
+ // Mark messages as seen
453
+ const thread = this.thread
454
+ if (thread && state.view === 'OPEN') {
455
+ thread.lastMessageSeen()
456
+ }
457
+ }
458
+
459
+ // Hide sticky availability
460
+ this.availability.scroll(state)
461
+ }
462
+
463
+ _closeChat (e) {
464
+ console.log('_closeChat')
465
+
466
+ const state = this.state
467
+
468
+ // History back
469
+ const isBtn = e instanceof window.PointerEvent || e instanceof window.MouseEvent || e instanceof window.KeyboardEvent
470
+ if (isBtn && state.isBack) {
471
+ state.back()
472
+ return
473
+ }
474
+
475
+ state.isOpen = false
476
+
477
+ // Reset timeout
478
+ if (this.timeout) {
479
+ this._resetTimeout()
480
+ }
481
+
482
+ const panel = this.panel
483
+ const instigatorId = state.instigatorId
484
+
485
+ // Toggle skiplink
486
+ const hasSkip = state.view === 'OPEN' || state.view === 'END'
487
+ this.skiplink.toggle(hasSkip)
488
+
489
+ // Remove panel
490
+ if (panel.container) {
491
+ panel.setAttributes(state)
492
+ panel.container = panel.container.remove()
493
+ state.replaceState()
494
+ }
495
+
496
+ // Update availability content
497
+ this.availability.update(state)
498
+ this.availability.scroll(state)
499
+
500
+ Keyboard.toggleInert()
501
+
502
+ // Move focus back to instigator
503
+ if (instigatorId) {
504
+ const instigator = document.getElementById(instigatorId)
505
+ if (instigator) {
506
+ document.getElementById(instigatorId).focus()
507
+ }
508
+ delete state.instigatorId
509
+ }
510
+ }
511
+
512
+ _timeoutChat () {
513
+ console.log('_timeoutChat')
514
+
515
+ // Close thread
516
+ window.localStorage.removeItem('THREAD_ID')
517
+
518
+ // Show timeout view
519
+ const state = this.state
520
+ state.view = 'TIMEOUT'
521
+ state.messages = []
522
+ this.panel.update(state)
523
+
524
+ // Clear timeout
525
+ this._resetTimeout()
526
+
527
+ // End thread if still open
528
+ const status = state.status
529
+ if (status && status !== 'closed') {
530
+ // *** Bug: Promise is void, why?
531
+ this.thread.endChat()
532
+ }
533
+
534
+ // Dont persist view, set to prechat
535
+ state.view = 'PRECHAT'
536
+ }
537
+
538
+ _prechat () {
539
+ const state = this.state
540
+ state.view = 'PRECHAT'
541
+ console.log('_prechat')
542
+ const panel = this.panel
543
+ panel.update(state)
544
+ panel.container.focus()
545
+ }
546
+
547
+ _start () {
548
+ const state = this.state
549
+ state.view = 'START'
550
+ console.log('_start')
551
+ const panel = this.panel
552
+ panel.update(state)
553
+ panel.container.focus()
554
+ }
555
+
556
+ _unavailable () {
557
+ const state = this.state
558
+ state.view = 'UNAVAILABLE'
559
+ console.log('_unavailable')
560
+ const panel = this.panel
561
+ panel.update(state)
562
+ panel.container.focus()
563
+
564
+ // Dont persist view, set to prechat
565
+ state.view = 'PRECHAT'
566
+ }
567
+
568
+ _endChat () {
569
+ const state = this.state
570
+ state.view = 'END'
571
+ console.log('_endChat')
572
+ this.panel.update(state)
573
+ const btn = document.querySelector('[data-wc-confirm-end-btn]')
574
+ btn.focus()
575
+ }
576
+
577
+ _resumeChat () {
578
+ const state = this.state
579
+ state.view = 'OPEN'
580
+ console.log('_resumeChat')
581
+ const panel = this.panel
582
+ panel.update(state)
583
+ panel.container.focus()
584
+ }
585
+
586
+ _feedback () {
587
+ console.log('_feedback')
588
+ const state = this.state
589
+ state.view = 'FEEDBACK'
590
+ this.panel.update(state)
591
+ const btn = document.querySelector('[data-wc-submit-feedback-btn]')
592
+ btn.focus()
593
+
594
+ // Dont persist view, set to prechat
595
+ state.view = 'PRECHAT'
596
+ }
597
+
598
+ _submitFeedback () {
599
+ console.log('_submitFeedback')
600
+ const state = this.state
601
+ state.view = 'FINISH'
602
+ this.panel.update(state)
603
+ const btn = document.querySelector('[role="button"][data-wc-close-btn]')
604
+ btn.focus()
605
+
606
+ // Dont persist view, set to prechat
607
+ state.view = 'PRECHAT'
608
+ }
609
+
610
+ _settings () {
611
+ const state = this.state
612
+ state.view = 'SETTINGS'
613
+ console.log('_settings')
614
+ const panel = this.panel
615
+ panel.update(state)
616
+ panel.container.focus()
617
+ }
618
+
619
+ _saveSettings () {
620
+ // Toggle settings
621
+ const state = this.state
622
+ const panel = this.panel
623
+ const audio = panel.container.querySelector('#audio')
624
+ const scroll = panel.container.querySelector('#scroll')
625
+ state.hasAudio = audio.checked
626
+ state.isScroll = scroll.checked
627
+
628
+ // Update local storage
629
+ window.localStorage.setItem('SETTINGS', [state.hasAudio, state.isScroll])
630
+
631
+ // Return to open view
632
+ state.view = 'OPEN'
633
+ console.log('_resumeChat')
634
+ panel.update(state)
635
+ panel.container.focus()
636
+ }
637
+
638
+ _mergeMessages (batch) {
639
+ // Create array of message objects
640
+ const messages = []
641
+ for (let i = 0; i < batch.length; i++) {
642
+ messages.push({
643
+ id: batch[i].id,
644
+ text: Utils.parseMessage(batch[i].messageContent.text),
645
+ user: batch[i].authorEndUserIdentity ? batch[i].authorEndUserIdentity.fullName.trim() : null,
646
+ assignee: batch[i].authorUser ? batch[i].authorUser.firstName : null,
647
+ date: Utils.formatDate(new Date(batch[i].createdAt)),
648
+ createdAt: new Date(batch[i].createdAt),
649
+ direction: batch[i].direction
650
+ })
651
+ }
652
+ messages.reverse()
653
+
654
+ // Merge with existing messafes
655
+ this.state.messages = messages.concat(this.state.messages)
656
+ }
657
+
658
+ _sendMessage (e) {
659
+ console.log('_sendMessage')
660
+
661
+ // Do we need to check here?
662
+ const message = document.getElementById('message')
663
+
664
+ if (!(message && message.value.length)) {
665
+ return
666
+ }
667
+
668
+ // *** Bug: Some times this results in inconsitent data error?
669
+ try {
670
+ this.thread.sendTextMessage(message.value.trim())
671
+ } catch (err) {
672
+ console.log(err)
673
+ }
674
+ }
675
+
676
+ _resetTimeout () {
677
+ // Return if not set
678
+ if (Config.timeout <= 0) {
679
+ return
680
+ }
681
+
682
+ // Clear existing timeout
683
+ clearTimeout(this.timeout)
684
+ clearInterval(this.countdown)
685
+
686
+ // We don't have an open thread
687
+ const state = this.state
688
+ const status = state.status
689
+ const view = state.view
690
+ if (!status || status === 'closed' || view === 'TIMEOUT') {
691
+ console.log('Timeout stopped...')
692
+ return
693
+ }
694
+
695
+ // Remove timeout cancel button
696
+ if (view === 'OPEN') {
697
+ this.panel.toggleTimeout(false)
698
+ }
699
+
700
+ // Restart timeout
701
+ console.log('Timeout started...')
702
+ this.timeout = setTimeout(this._handleTimeout.bind(this), Config.timeout * 1000)
703
+ }
704
+
705
+ _download () {
706
+ console.log('download')
707
+
708
+ // Need to test for accessibility of this approach
709
+ const messages = this.state.messages
710
+ const transcript = new Transcript(messages)
711
+ const data = transcript.data
712
+ const anchor = document.createElement('a')
713
+ anchor.className = 'govuk-visually-hidden'
714
+ anchor.setAttribute('href', data)
715
+ anchor.setAttribute('download', 'transcript.txt')
716
+ document.body.appendChild(anchor)
717
+ anchor.click()
718
+ document.body.removeChild(anchor)
719
+ }
720
+
721
+ _alertAT (text, politeness = 'polite') {
722
+ // Get reference to live element
723
+ const el = document.querySelector('[data-wc-panel-live], [data-wc-availability-live]')
724
+ el.setAttribute('aria-live', politeness)
725
+ el.innerHTML = `<p>${text}</p>`
726
+ setTimeout(() => { el.innerHTML = '' }, 1000)
727
+ }
728
+
729
+ //
730
+ // Event handlers
731
+ //
732
+
733
+ // _handleConsumerAuthorizedEvent (e) {
734
+ // console.log('_handleConsumerAuthorizedEvent')
735
+ // }
736
+
737
+ async _handleLivechatRecoveredEvent (e) {
738
+ console.log('_handleLivechatRecoveredEvent')
739
+
740
+ // Set thread status
741
+ const state = this.state
742
+ const status = e.detail.data.contact.status
743
+ state.status = status
744
+
745
+ // Calculate elapsed time
746
+ let messages = e.detail.data.messages
747
+ const timeout = Config.timeout
748
+ const countdown = Config.countdown
749
+ const latestDatetime = new Date(messages[0].createdAt)
750
+ const elapsed = Math.abs((new Date()) - latestDatetime) / 1000
751
+ const isExpired = timeout > 0 && (timeout + countdown) - elapsed <= 0
752
+
753
+ // End chat if elapsed time outside allowance
754
+ if (isExpired) {
755
+ window.localStorage.removeItem('THREAD_ID')
756
+ state.view = 'TIMEOUT'
757
+ if (state.status !== 'closed') {
758
+ // *** Bug: Promise is void, why?
759
+ this.thread.endChat()
760
+ }
761
+
762
+ // Ready
763
+ document.dispatchEvent(this.livechatReady)
764
+ return
765
+ }
766
+
767
+ // Set assignee and unseen message count
768
+ state.view = 'OPEN'
769
+ const assignee = e.detail.data.inboxAssignee
770
+ state.assignee = assignee ? assignee.nickname || assignee.firstName : null
771
+ const unseen = e.detail.data.thread.unseenByEndUserMessagesCount || 0
772
+ state.unseen = unseen
773
+
774
+ // Conditionally mark messages as seen
775
+ if (state.isOpen) {
776
+ this.thread.lastMessageSeen()
777
+ }
778
+
779
+ // Recursively merge messages with previous messages
780
+ while (messages.length) {
781
+ this._mergeMessages(messages)
782
+ try {
783
+ const response = await this.thread.loadMoreMessages()
784
+ messages = response.data.messages
785
+ } catch (err) {
786
+ console.log(err)
787
+ messages = []
788
+ }
789
+ }
790
+ console.log('All messages loaded')
791
+
792
+ // Remove duplicates?? LoadMoreMessages doesnt always get a unique set?
793
+ state.messages = [...new Map(state.messages.map(m => [m.id, m])).values()]
794
+
795
+ // Sort on date
796
+ state.messages = Utils.sortMessages(state.messages)
797
+
798
+ // Add html to message objects
799
+ state.messages = Utils.addMessagesHtml(state.messages)
800
+
801
+ // Ready
802
+ document.dispatchEvent(this.livechatReady)
803
+ }
804
+
805
+ _handleReadyEvent (e) {
806
+ console.log('_handleReadyEvent')
807
+
808
+ // Start/reset timeout
809
+ this._resetTimeout()
810
+
811
+ // Poll availability
812
+ let isInit = true
813
+ const interval = Config.poll > 0 ? Config.poll * 1000 : 0
814
+ const state = this.state
815
+ const panel = this.panel
816
+ const availability = this.availability
817
+ const availabilityEndPoint = this.availabilityEndpoint
818
+
819
+ Utils.poll({
820
+ fn: async () => {
821
+ console.log('Polling availability')
822
+ state.availability = await Utils.getAvailability(availabilityEndPoint)
823
+
824
+ // Set view
825
+ if (state.view !== 'OPEN') {
826
+ state.view = state.availability === 'AVAILABLE' ? 'PRECHAT' : 'UNAVAILABLE'
827
+ }
828
+
829
+ // Update panel on page load only
830
+ if (isInit) {
831
+ panel.update(state)
832
+ }
833
+
834
+ // Update availability on each poll
835
+ availability.update(state)
836
+ availability.scroll(state)
837
+
838
+ // Remove init flag
839
+ isInit = false
840
+ },
841
+ interval
842
+ })
843
+ }
844
+
845
+ _handleContactStatusChangedEvent (e) {
846
+ console.log('_handleContactStatusChangedEvent', this.state.view)
847
+
848
+ const state = this.state
849
+ state.status = e.detail.data.case.status
850
+ const panel = this.panel
851
+
852
+ // ***Todo: Need a more robust way to determine who instigated this
853
+ const isEndedByAdviser = state.messages.length
854
+
855
+ // Currently only responding to a closed case
856
+ if (state.status === 'closed') {
857
+ // Instigated by adviser
858
+ if (isEndedByAdviser && state.view === 'OPEN') {
859
+ panel.updateHeader(state)
860
+
861
+ // Alert assistive technology
862
+ const el = document.querySelector('[data-wc-status]')
863
+ const text = el ? el.innerHTML : ''
864
+ this._alertAT(text)
865
+
866
+ // Start/reset timeout
867
+ this._resetTimeout()
868
+ }
869
+
870
+ // Instigated by end user
871
+ if (!isEndedByAdviser) {
872
+ this._updateAvailability()
873
+ }
874
+ }
875
+ }
876
+
877
+ _handleAssignedAgentChangedEvent (e) {
878
+ console.log('_handleAssignedAgentChangedEvent')
879
+
880
+ const assignee = e.detail.data.inboxAssignee
881
+ const state = this.state
882
+ state.assignee = assignee ? assignee.nickname || assignee.firstName : null
883
+
884
+ // ***Limitation: Adviser availability doesn't fire an event in the SDK
885
+ if (assignee && !['AVAILABLE', 'EXISTING'].includes(state.availability)) {
886
+ state.availability = 'EXISTING'
887
+ }
888
+
889
+ // Update header
890
+ if (state.view === 'OPEN') {
891
+ const panel = this.panel
892
+ panel.updateHeader(state)
893
+ }
894
+
895
+ // Alert assistive technology
896
+ const el = document.querySelector('[data-wc-status]')
897
+ const text = el ? el.innerHTML : ''
898
+ this._alertAT(text)
899
+ }
900
+
901
+ _handleContactCreatedEvent (e) {
902
+ console.log('_handleContactCreatedEvent')
903
+ }
904
+
905
+ _handleMessageCreatedEvent (e) {
906
+ console.log('_handleMessageCreatedEvent')
907
+
908
+ const state = this.state
909
+ const response = e.detail.data.message
910
+ const assignee = response.authorUser ? response.authorUser.firstName : null
911
+ const user = response.authorEndUserIdentity ? response.authorEndUserIdentity.fullName.trim() : null
912
+ const direction = response.direction.toLowerCase()
913
+ state.status = e.detail.data.case.status
914
+ state.assignee = assignee
915
+
916
+ // Update messages array
917
+ const message = {
918
+ id: response.id,
919
+ text: Utils.parseMessage(response.messageContent.text),
920
+ user,
921
+ assignee,
922
+ date: Utils.formatDate(new Date(response.createdAt)),
923
+ createdAt: new Date(response.createdAt),
924
+ direction
925
+ }
926
+ state.messages.push(message)
927
+
928
+ // Add html to messages
929
+ state.messages = Utils.addMessagesHtml(state.messages)
930
+
931
+ // Update unseen count
932
+ if (direction === 'outbound' && !state.isOpen) {
933
+ state.unseen += 1
934
+ this.availability.update(state)
935
+ this.availability.scroll(state)
936
+
937
+ // Alert assistive technology
938
+ const text = `${state.unseen} new message${state.unseen > 1 ? 's' : ''}`
939
+ this._alertAT(text)
940
+ }
941
+
942
+ // Clear input
943
+ const textbox = document.querySelector('[data-wc-textbox]')
944
+ if (textbox && direction === 'inbound') {
945
+ textbox.value = ''
946
+ textbox.style.height = 'auto'
947
+ const event = new Event('change')
948
+ textbox.dispatchEvent(event)
949
+ }
950
+
951
+ // Update messages
952
+ const panel = this.panel
953
+ if (state.view === 'START') {
954
+ // Update panel if new question
955
+ state.view = 'OPEN'
956
+ panel.update(state)
957
+
958
+ // Set focus to panel
959
+ panel.container.focus()
960
+ } else if (state.view === 'OPEN' && state.isOpen) {
961
+ // Add message if existing thread
962
+ panel.addMessage(state, message)
963
+
964
+ // Mark as seen
965
+ if (this.thread) {
966
+ this.thread.lastMessageSeen()
967
+ }
968
+
969
+ // Alert assistive technology
970
+ const author = message.direction === 'outbound' ? message.assignee : 'You'
971
+ const text = `${author} said: ${message.text}`
972
+ this._alertAT(text)
973
+
974
+ // Set focus to message field
975
+ const el = document.getElementById('message')
976
+ if (el && direction === 'inbound') {
977
+ el.focus()
978
+ }
979
+ }
980
+
981
+ // Play notification sound
982
+ if (state.hasAudio && direction === 'outbound') {
983
+ const notification = this.notification
984
+ notification.playSound()
985
+ }
986
+
987
+ // Start/reset timeout
988
+ this._resetTimeout()
989
+ }
990
+
991
+ _handleAgentTypingEvent (e) {
992
+ console.log('_handleAgentTypingEvent')
993
+
994
+ // Event may fire when list is not available
995
+ const list = document.querySelector('[data-wc-list]')
996
+ if (!list) {
997
+ return
998
+ }
999
+
1000
+ // Reset timeout
1001
+ this._resetTimeout()
1002
+
1003
+ // Toggle agent typing
1004
+ const state = this.state
1005
+ const panel = this.panel
1006
+ const isTyping = e.type === 'AgentTypingStarted'
1007
+ state.assignee = e.detail.data.user.firstName
1008
+ panel.toggleAgentTyping(state, isTyping)
1009
+
1010
+ // ***Limitation: Adviser availability doesn't fire an event in the SDK
1011
+ // ***Bug: CaseInboxAssigneeChanged/AssignedAgentChanged not always firing
1012
+ if (!state.assignee || !['AVAILABLE', 'EXISTING'].includes(state.availability)) {
1013
+ state.availability = 'EXISTING'
1014
+ panel.updateHeader(state)
1015
+
1016
+ // Alert assistive technology
1017
+ const el = document.querySelector('[data-wc-status]')
1018
+ const text = el ? el.innerHTML : ''
1019
+ this._alertAT(text)
1020
+ }
1021
+
1022
+ // Alert assistive technology
1023
+ if (isTyping) {
1024
+ this._alertAT(`${state.assignee} is typing`)
1025
+ }
1026
+ }
1027
+
1028
+ _handleMessageSeenByEndUserEvent (e) {
1029
+ console.log('_handleMessageSeenByEndUserEvent')
1030
+
1031
+ // Clear unseen count
1032
+ const state = this.state
1033
+ state.unseen = 0
1034
+ this.availability.update(state)
1035
+ }
1036
+
1037
+ _handleTimeout (e) {
1038
+ console.log('Countdown starting...')
1039
+
1040
+ // Show timeout notification
1041
+ const state = this.state
1042
+ const panel = this.panel
1043
+ if (state.view === 'OPEN') {
1044
+ panel.toggleTimeout(state, true)
1045
+ }
1046
+
1047
+ // Set countdown
1048
+ const element = panel.container.querySelector('[data-wc-countdown]')
1049
+ this.countdown = Utils.setCountdown(element, () => {
1050
+ console.log('Count down ended')
1051
+ if (panel.container) {
1052
+ this._timeoutChat()
1053
+ } else {
1054
+ console.log('Chat has timedout')
1055
+ }
1056
+ })
1057
+ }
1058
+
1059
+ _handleSendKeystrokeEvent () {
1060
+ console.log('_handleSendKeystrokeEvent')
1061
+
1062
+ this.thread.keystroke(1000)
1063
+ setTimeout(() => {
1064
+ this.thread.stopTyping()
1065
+ }, 1000)
1066
+ }
1067
+
1068
+ _handleChatEvent (e) {
1069
+ console.log('Chat event: ', e)
1070
+ }
1071
+ }
1072
+
1073
+ export default WebChat