@defra/flood-webchat 0.0.1-alpha.3 → 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,27 @@
1
+ import nunjucks from './nunjucks.js'
2
+
3
+ class Skiplink {
4
+ constructor () {
5
+ this.target = document.querySelector('a[data-module="govuk-skip-link"]')
6
+ this.skiplink = null
7
+ }
8
+
9
+ toggle (hasSkip) {
10
+ const target = this.target
11
+ const skiplink = this.skiplink
12
+ if (!target) {
13
+ return
14
+ }
15
+
16
+ if (hasSkip && !skiplink) {
17
+ // Add skiplink
18
+ target.insertAdjacentHTML('afterend', nunjucks.render('skiplink.html'))
19
+ this.skiplink = target.nextElementSibling
20
+ } else if (skiplink && !hasSkip) {
21
+ // Remove skiplink
22
+ this.skiplink.remove()
23
+ }
24
+ }
25
+ }
26
+
27
+ export default Skiplink
@@ -0,0 +1,82 @@
1
+ import Utils from './utils.js'
2
+
3
+ class State {
4
+ constructor (openChat, closeChat) {
5
+ const threadId = window.localStorage.getItem('THREAD_ID')
6
+ const isBack = window.sessionStorage.getItem('IS_BACK') === 'true'
7
+ const isOpen = window.location.hash === '#webchat'
8
+ const view = threadId ? 'OPEN' : null
9
+
10
+ // Settings
11
+ const settings = window.localStorage.getItem('SETTINGS')
12
+ const hasAudio = settings ? settings.split(',')[0] : true
13
+ const isScroll = settings ? settings.split(',')[1] : true
14
+
15
+ // Public properties
16
+ this.threadId = threadId
17
+ this.availability = null
18
+ this.status = null
19
+ this.view = view
20
+ this.assignee = null
21
+ this.unseen = 0
22
+ this.isAuthorised = false
23
+ this.hasAudio = hasAudio
24
+ this.isScroll = isScroll
25
+ this.isMobile = true
26
+ this.isBack = isBack
27
+ this.isOpen = isOpen
28
+ this.messages = []
29
+ this.name = null
30
+ this.question = null
31
+
32
+ // Private methods (callbacks)
33
+ this._openChat = openChat
34
+ this._closeChat = closeChat
35
+
36
+ // Help with browser back behaviour
37
+ if (window.history.length <= 1) {
38
+ this._isBack = false
39
+ window.sessionStorage.removeItem('IS_BACK')
40
+ }
41
+
42
+ // We need to toggle some attributes depending on screen size
43
+ Utils.listenForDevice('mobile', (isMobile) => { this.isMobile = isMobile })
44
+
45
+ // Events
46
+ window.addEventListener('popstate', this._popstate.bind(this))
47
+ }
48
+
49
+ _popstate (e) {
50
+ if (window.location.hash === '#webchat') {
51
+ this._openChat(e)
52
+ } else {
53
+ this._closeChat(e)
54
+ }
55
+ }
56
+
57
+ replaceState () {
58
+ this.isOpen = false
59
+ const url = window.location.href.split('#')[0]
60
+ window.history.replaceState(null, null, url)
61
+ }
62
+
63
+ pushState () {
64
+ this._isOpen = true
65
+ this._isBack = true
66
+ const url = `${window.location.href.split('#')[0]}#webchat`
67
+ window.history.pushState({ view: 'webchat', isBack: true }, '', url)
68
+ window.sessionStorage.setItem('IS_BACK', true)
69
+ }
70
+
71
+ back () {
72
+ const offsetY = window.scrollY
73
+ window.history.back()
74
+ if (!this._isMobile) {
75
+ window.addEventListener('scroll', e => {
76
+ window.scrollTo(0, offsetY)
77
+ }, { once: true })
78
+ }
79
+ }
80
+ }
81
+
82
+ export default State
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ class Transcript {
4
+ constructor (messages) {
5
+ const text = this._buildString(messages)
6
+ this.data = `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`
7
+ }
8
+
9
+ _buildString (messages) {
10
+ const dateNow = this._formatDate(new Date())
11
+ let string = `Floodline webchat transcript at ${dateNow}\n\n`
12
+ for (let i = 0; i < messages.length; i++) {
13
+ const author = messages[i].direction === 'inbound' ? messages[i].user : `${messages[i].assignee} (Floodline adviser)`
14
+ const date = this._formatDate(messages[i].createdAt)
15
+ string += `${author} at ${date}\n${messages[i].text}\n\n`
16
+ }
17
+ string = string.replace(/<a\b[^>]*>/i, '').replace(/<\/a>/i, '')
18
+ return string
19
+ }
20
+
21
+ _formatDate (datetime) {
22
+ let hours = datetime.getHours()
23
+ let minutes = datetime.getMinutes()
24
+ minutes = `${minutes < 10 ? '0' : ''}${minutes}`
25
+ const ampm = hours >= 12 ? 'pm' : 'am'
26
+ hours %= 12
27
+ hours = hours || 12
28
+ const time = `${hours}:${minutes}${ampm}`
29
+ const date = datetime.toLocaleString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
30
+ return `${time}, ${date}`
31
+ }
32
+ }
33
+
34
+ export default Transcript
@@ -0,0 +1,190 @@
1
+ import Config from './config.js'
2
+ import nunjucks from './nunjucks.js'
3
+
4
+ const env = nunjucks
5
+
6
+ class Utils {
7
+ static addOrUpdateParameter (uri, key, value) {
8
+ // Temporariliy remove fragment
9
+ const i = uri.indexOf('#')
10
+ const hash = i === -1 ? '' : uri.substr(i)
11
+ uri = i === -1 ? uri : uri.substr(0, i)
12
+ const re = new RegExp('([?&])' + key + '=[^&#]*', 'i')
13
+ // Delete parameter and value
14
+ if (!value || value === '') {
15
+ uri = uri.replace(re, '')
16
+ } else if (re.test(uri)) {
17
+ // Replace parameter value
18
+ uri = uri.replace(re, '$1' + key + '=' + value)
19
+ // Add parameter and value
20
+ } else {
21
+ const separator = /\?/.test(uri) ? '&' : '?'
22
+ uri = uri + separator + key + '=' + value
23
+ }
24
+ return uri + hash
25
+ }
26
+
27
+ static getParameterByName (name) {
28
+ const v = window.location.search.match(new RegExp('(?:[?&]' + name + '=)([^&]+)'))
29
+ return v ? v[1] : null
30
+ }
31
+
32
+ static listenForDevice (device, callback) {
33
+ const mQ = window.matchMedia(`(max-width: ${Config.getBreakpoint(device)})`)
34
+ if (window.matchMedia.addEventListener) {
35
+ mQ.addEventListener('change', e => { callback(e.matches) })
36
+ } else {
37
+ mQ.addListener(e => { callback(e.matches) })
38
+ }
39
+ callback(mQ.matches)
40
+ }
41
+
42
+ static formatDate (value) {
43
+ const now = new Date().getTime()
44
+ const startOfDay = now - (now % 86400000)
45
+ const isToday = value.getTime() >= startOfDay
46
+ let hours = value.getHours()
47
+ let minutes = value.getMinutes()
48
+ minutes = `${minutes < 10 ? '0' : ''}${minutes}`
49
+ const ampm = hours >= 12 ? 'pm' : 'am'
50
+ hours %= 12
51
+ hours = hours || 12
52
+ const time = `${hours}:${minutes}${ampm}`
53
+ const date = value.toLocaleString('en-GB', { day: 'numeric', month: 'short' })
54
+ return isToday ? time : `${time}, ${date}`
55
+ }
56
+
57
+ static parseMessage (input) {
58
+ let text = input
59
+
60
+ // Convert line breaks
61
+ text = text.trim().replace(/(\r\n|\r|\n){2,}/g, '$1\n').replace(/\r\n|\r|\n/g, '<br>')
62
+
63
+ // Convert links
64
+ const linksFound = text.match(/(?:www|https?)[^\s]+/g)
65
+ const aLink = []
66
+ if (linksFound != null) {
67
+ for (let i = 0; i < linksFound.length; i++) {
68
+ const href = linksFound[i]
69
+ const anchor = linksFound[i].replace(/#webchat|https?:\/\//gi, '')
70
+ aLink.push(`<a href="${href}">${anchor}</a>`)
71
+ text = text.split(linksFound[i]).join(aLink[i])
72
+ }
73
+ }
74
+
75
+ return text
76
+ }
77
+
78
+ static sortMessages (messages) {
79
+ return messages.sort((a, b) => {
80
+ return a.createdAt - b.createdAt
81
+ })
82
+ }
83
+
84
+ static addMessagesHtml (messages) {
85
+ const m = messages
86
+ for (let i = 0; i < m.length; i++) {
87
+ const isGroupStart = i === 0 || (i > 0 && m[i].direction !== m[i - 1].direction)
88
+ const isGroupEnd = (i === m.length - 1) || (i < (m.length - 1) && m[i].direction !== m[i + 1].direction)
89
+ m[i].isGroupStart = isGroupStart
90
+ m[i].isGroupEnd = isGroupEnd
91
+ const html = env.render('message.html', { model: m[i] })
92
+ m[i].html = html
93
+ }
94
+ return m
95
+ }
96
+
97
+ static setCountdown (element, callback) {
98
+ let second = Config.countdown
99
+ const interval = setInterval(() => {
100
+ second--
101
+ if (element) {
102
+ element.innerHTML = `${second} seconds`
103
+ }
104
+ if (second <= 0) {
105
+ clearInterval(interval)
106
+ callback()
107
+ }
108
+ }, 1000)
109
+ return interval
110
+ }
111
+
112
+ static toggleLabel (label, key, textbox) {
113
+ const chars = /^[a-zA-Z0-9- !'^+%&/()=?_\-~`;#$½{[\]}\\|<>@,]+$/i // /^[a-z\d -]+$/i
114
+ const hasValue = textbox.value.length > 0
115
+ const isValidChar = key && key.length === 1 && chars.test(key)
116
+ const isHidden = hasValue || (!hasValue && isValidChar)
117
+ label.classList.toggle('wc-message__label--hidden', isHidden)
118
+ }
119
+
120
+ static submit (e, textbox) {
121
+ const form = textbox.closest('form')
122
+ const isEnterSubmit = textbox.hasAttribute('data-wc-enter-submit')
123
+ if (e.key !== 'Enter' || !isEnterSubmit || e.altKey || e.shiftKey) {
124
+ return
125
+ }
126
+ form.dispatchEvent(new Event('submit'))
127
+ }
128
+
129
+ static suppressEnter (e, textbox) {
130
+ const isEnterSubmit = textbox.hasAttribute('data-wc-enter-submit')
131
+ const hasValue = textbox.value.length > 0
132
+ if (e.key === 'Enter' && (!hasValue || (isEnterSubmit && !e.altKey && !e.shiftKey))) {
133
+ e.preventDefault()
134
+ }
135
+ }
136
+
137
+ static autosize (textbox) {
138
+ const offset = textbox.offsetHeight - textbox.clientHeight
139
+ textbox.addEventListener('input', e => {
140
+ e.target.style.height = 'auto'
141
+ e.target.style.height = e.target.scrollHeight + offset + 'px'
142
+ })
143
+ }
144
+
145
+ static async poll ({ fn, validate, interval, maxAttempts }) {
146
+ let attempts = 0
147
+
148
+ const executePoll = async (resolve, reject) => {
149
+ const result = await fn()
150
+ attempts++
151
+
152
+ if (interval <= 0) {
153
+ return
154
+ }
155
+
156
+ if (validate && validate(result)) {
157
+ return resolve(result)
158
+ } else if (maxAttempts && attempts === maxAttempts) {
159
+ return reject(new Error('Exceeded max attempts'))
160
+ } else {
161
+ setTimeout(executePoll, interval, resolve, reject)
162
+ }
163
+ }
164
+
165
+ return new Promise(executePoll)
166
+ }
167
+
168
+ static async getAvailability (endpoint) {
169
+ try {
170
+ const response = await fetch(endpoint)
171
+ const data = await response.json()
172
+ return data.availability
173
+ } catch (err) {
174
+ console.log('getAvailability', err)
175
+ return 'UNAVAILABLE'
176
+ }
177
+ }
178
+
179
+ static getDuration (seconds) {
180
+ let duration = seconds
181
+ if (seconds <= 60) {
182
+ duration = `${seconds} seconds`
183
+ } else {
184
+ duration = `${Math.floor(seconds / 60)} minutes`
185
+ }
186
+ return duration
187
+ }
188
+ }
189
+
190
+ export default Utils