@hellotext/hellotext 2.4.4 → 2.4.5
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/api/webchat/messages.cjs +8 -0
- package/lib/api/webchat/messages.js +8 -0
- package/lib/channels/application_channel.cjs +143 -10
- package/lib/channels/application_channel.js +143 -10
- package/lib/channels/webchat_channel.cjs +57 -0
- package/lib/channels/webchat_channel.js +57 -0
- package/lib/controllers/webchat_controller.cjs +143 -22
- package/lib/controllers/webchat_controller.js +148 -18
- package/package.json +1 -1
- package/src/api/webchat/messages.js +7 -0
- package/src/channels/application_channel.js +152 -9
- package/src/channels/webchat_channel.js +61 -1
- package/src/controllers/webchat_controller.js +157 -21
|
@@ -2,6 +2,20 @@ import { Configuration } from '../core'
|
|
|
2
2
|
|
|
3
3
|
class ApplicationChannel {
|
|
4
4
|
static webSocket
|
|
5
|
+
static channels = new Set()
|
|
6
|
+
static messageHandlers = new Set()
|
|
7
|
+
static disconnectHandlers = new Set()
|
|
8
|
+
static subscriptionConfirmHandlers = new Set()
|
|
9
|
+
static reconnectTimeout = null
|
|
10
|
+
static reconnectAttempts = 0
|
|
11
|
+
static reconnectBaseDelay = 500
|
|
12
|
+
static reconnectMaxDelay = 10000
|
|
13
|
+
static reconnectJitter = 0.3
|
|
14
|
+
static needsResubscribe = false
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
ApplicationChannel.channels.add(this)
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
send({ command, identifier, data }) {
|
|
7
21
|
const payload = {
|
|
@@ -10,17 +24,20 @@ class ApplicationChannel {
|
|
|
10
24
|
data: JSON.stringify(data || {}),
|
|
11
25
|
}
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
const socket = ApplicationChannel.ensureWebSocket()
|
|
28
|
+
const message = JSON.stringify(payload)
|
|
29
|
+
|
|
30
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
31
|
+
socket.send(message)
|
|
15
32
|
} else {
|
|
16
|
-
|
|
17
|
-
|
|
33
|
+
socket.addEventListener('open', () => {
|
|
34
|
+
socket.send(message)
|
|
18
35
|
})
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
onMessage(callback) {
|
|
23
|
-
|
|
40
|
+
const handler = event => {
|
|
24
41
|
const data = JSON.parse(event.data)
|
|
25
42
|
const { type, message } = data
|
|
26
43
|
|
|
@@ -29,15 +46,141 @@ class ApplicationChannel {
|
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
callback(message)
|
|
32
|
-
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ApplicationChannel.messageHandlers.add(handler)
|
|
52
|
+
ApplicationChannel.ensureWebSocket().addEventListener('message', handler)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onDisconnect(callback) {
|
|
56
|
+
ApplicationChannel.disconnectHandlers.add(callback)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onSubscriptionConfirmed(callback) {
|
|
60
|
+
ApplicationChannel.subscriptionConfirmHandlers.add(callback)
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
get webSocket() {
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
return ApplicationChannel.ensureWebSocket()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static ensureWebSocket() {
|
|
68
|
+
if (this.webSocket && !this.closedWebSocket(this.webSocket)) {
|
|
69
|
+
return this.webSocket
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.webSocket) {
|
|
73
|
+
this.needsResubscribe = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return this.openWebSocket()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static openWebSocket() {
|
|
80
|
+
this.clearReconnectTimeout()
|
|
81
|
+
|
|
82
|
+
const socket = new WebSocket(Configuration.actionCableUrl)
|
|
83
|
+
this.webSocket = socket
|
|
84
|
+
this.installWebSocketHandlers(socket)
|
|
85
|
+
|
|
86
|
+
return socket
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static installWebSocketHandlers(socket) {
|
|
90
|
+
socket.addEventListener('open', () => this.handleOpen(socket))
|
|
91
|
+
socket.addEventListener('close', () => this.handleDisconnect(socket))
|
|
92
|
+
socket.addEventListener('error', () => this.handleDisconnect(socket))
|
|
93
|
+
socket.addEventListener('message', event => this.handleControlMessage(event))
|
|
94
|
+
|
|
95
|
+
this.messageHandlers.forEach(handler => {
|
|
96
|
+
socket.addEventListener('message', handler)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static handleOpen(socket) {
|
|
101
|
+
if (socket !== this.webSocket) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.reconnectAttempts = 0
|
|
106
|
+
|
|
107
|
+
if (!this.needsResubscribe) {
|
|
108
|
+
return
|
|
38
109
|
}
|
|
39
110
|
|
|
40
|
-
|
|
111
|
+
this.needsResubscribe = false
|
|
112
|
+
this.resubscribeChannels()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static handleControlMessage(event) {
|
|
116
|
+
let data
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
data = JSON.parse(event.data)
|
|
120
|
+
} catch {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (data.type !== 'confirm_subscription') {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.subscriptionConfirmHandlers.forEach(callback => callback(data.identifier))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static handleDisconnect(socket) {
|
|
132
|
+
if (socket !== this.webSocket) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.disconnectHandlers.forEach(callback => callback())
|
|
137
|
+
this.webSocket = null
|
|
138
|
+
this.needsResubscribe = true
|
|
139
|
+
this.scheduleReconnect()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
static scheduleReconnect() {
|
|
143
|
+
if (this.reconnectTimeout) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
148
|
+
this.reconnectTimeout = null
|
|
149
|
+
this.reconnectAttempts += 1
|
|
150
|
+
this.openWebSocket()
|
|
151
|
+
}, this.reconnectDelay)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
static clearReconnectTimeout() {
|
|
155
|
+
if (this.reconnectTimeout) {
|
|
156
|
+
clearTimeout(this.reconnectTimeout)
|
|
157
|
+
this.reconnectTimeout = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static resubscribeChannels() {
|
|
162
|
+
this.channels.forEach(channel => {
|
|
163
|
+
const resubscribe = channel.resubscribe || channel.subscribe
|
|
164
|
+
|
|
165
|
+
if (typeof resubscribe === 'function') {
|
|
166
|
+
resubscribe.call(channel)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static closedWebSocket(socket) {
|
|
172
|
+
return socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
static get reconnectDelay() {
|
|
176
|
+
const delay = Math.min(
|
|
177
|
+
this.reconnectMaxDelay,
|
|
178
|
+
this.reconnectBaseDelay * 2 ** this.reconnectAttempts,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const jitter = Math.round(delay * this.reconnectJitter * Math.random())
|
|
182
|
+
|
|
183
|
+
return delay + jitter
|
|
41
184
|
}
|
|
42
185
|
|
|
43
186
|
get ignoredEvents() {
|
|
@@ -7,11 +7,24 @@ class WebchatChannel extends ApplicationChannel {
|
|
|
7
7
|
this.id = id
|
|
8
8
|
this.session = session
|
|
9
9
|
this.conversation = conversation
|
|
10
|
-
|
|
10
|
+
// Keep our own subscription intent instead of trusting the socket state.
|
|
11
|
+
// The shared WebSocket can reconnect independently, but an explicit
|
|
12
|
+
// unsubscribe means this channel should not silently join again.
|
|
13
|
+
this.subscribed = false
|
|
14
|
+
this.awaitingReconnectConfirmation = false
|
|
15
|
+
this.reconnectCallbacks = new Set()
|
|
16
|
+
|
|
17
|
+
// ActionCable confirms subscriptions at the socket level. This channel
|
|
18
|
+
// listens for those confirmations so the controller can wait until Rails has
|
|
19
|
+
// accepted this exact WebchatChannel subscription before fetching missed
|
|
20
|
+
// messages from the REST catch-up endpoint.
|
|
21
|
+
this.onSubscriptionConfirmed(identifier => this.handleSubscriptionConfirmed(identifier))
|
|
11
22
|
this.subscribe()
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
subscribe() {
|
|
26
|
+
this.subscribed = true
|
|
27
|
+
|
|
15
28
|
const params = {
|
|
16
29
|
channel: 'WebchatChannel',
|
|
17
30
|
id: this.id,
|
|
@@ -23,6 +36,8 @@ class WebchatChannel extends ApplicationChannel {
|
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
unsubscribe() {
|
|
39
|
+
this.subscribed = false
|
|
40
|
+
|
|
26
41
|
const params = {
|
|
27
42
|
channel: 'WebchatChannel',
|
|
28
43
|
id: this.id,
|
|
@@ -33,6 +48,51 @@ class WebchatChannel extends ApplicationChannel {
|
|
|
33
48
|
this.send({ command: 'unsubscribe', identifier: params })
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
resubscribe() {
|
|
52
|
+
if (this.subscribed === false) return
|
|
53
|
+
|
|
54
|
+
// Reconnect recovery has two phases: send the subscription command first,
|
|
55
|
+
// then wait for ActionCable to confirm it. Catch-up work should run after
|
|
56
|
+
// that confirmation so broadcasts and REST backfill are pointed at the same
|
|
57
|
+
// live conversation subscription.
|
|
58
|
+
this.awaitingReconnectConfirmation = true
|
|
59
|
+
this.subscribe()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onReconnect(callback) {
|
|
63
|
+
this.reconnectCallbacks.add(callback)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handleSubscriptionConfirmed(identifier) {
|
|
67
|
+
if (!this.awaitingReconnectConfirmation || !this.matchesIdentifier(identifier)) return
|
|
68
|
+
|
|
69
|
+
// Only the first matching confirmation completes the reconnect cycle. This
|
|
70
|
+
// prevents unrelated subscription confirmations on the shared socket from
|
|
71
|
+
// triggering duplicate catch-up requests.
|
|
72
|
+
this.awaitingReconnectConfirmation = false
|
|
73
|
+
this.reconnectCallbacks.forEach(callback => callback())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
matchesIdentifier(identifier) {
|
|
77
|
+
let params
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
params = typeof identifier === 'string' ? JSON.parse(identifier) : identifier
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ActionCable sends the same identifier payload we used when subscribing.
|
|
86
|
+
// Matching every routing key keeps a confirmation for another webchat,
|
|
87
|
+
// session, or conversation from being treated as this channel's reconnect.
|
|
88
|
+
return (
|
|
89
|
+
params.channel === 'WebchatChannel' &&
|
|
90
|
+
params.id === this.id &&
|
|
91
|
+
params.session === this.session &&
|
|
92
|
+
params.conversation === this.conversation
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
36
96
|
startTypingIndicator() {
|
|
37
97
|
const params = {
|
|
38
98
|
channel: 'WebchatChannel',
|
|
@@ -18,6 +18,8 @@ const MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
|
|
|
18
18
|
hour: 'numeric',
|
|
19
19
|
minute: '2-digit',
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
const MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i
|
|
21
23
|
const SCROLL_ISOLATION_EVENT_OPTIONS = { capture: true, passive: true }
|
|
22
24
|
|
|
23
25
|
export default class extends Controller {
|
|
@@ -84,16 +86,22 @@ export default class extends Controller {
|
|
|
84
86
|
|
|
85
87
|
this.files = []
|
|
86
88
|
this.messageIds = new Set()
|
|
89
|
+
this.catchUpAfterMessageId = null
|
|
90
|
+
this.fetchingCatchUpMessages = false
|
|
87
91
|
|
|
88
92
|
this.onMessageReceived = this.onMessageReceived.bind(this)
|
|
89
93
|
this.onMessageReaction = this.onMessageReaction.bind(this)
|
|
90
94
|
this.onTypingStart = this.onTypingStart.bind(this)
|
|
95
|
+
this.captureCatchUpCursor = this.captureCatchUpCursor.bind(this)
|
|
96
|
+
this.catchUpMessages = this.catchUpMessages.bind(this)
|
|
91
97
|
|
|
92
98
|
this.onScroll = this.onScroll.bind(this)
|
|
93
99
|
|
|
94
100
|
this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this)
|
|
95
101
|
this.closePopoverOnEscape = this.closePopoverOnEscape.bind(this)
|
|
96
102
|
this.broadcastChannel = new BroadcastChannel(`hellotext--webchat--${this.idValue}`)
|
|
103
|
+
this.webChatChannel.onDisconnect(this.captureCatchUpCursor)
|
|
104
|
+
this.webChatChannel.onReconnect(this.catchUpMessages)
|
|
97
105
|
|
|
98
106
|
super.initialize()
|
|
99
107
|
}
|
|
@@ -330,7 +338,14 @@ export default class extends Controller {
|
|
|
330
338
|
|
|
331
339
|
const element = this.messageTemplateTarget.cloneNode(true)
|
|
332
340
|
|
|
341
|
+
element.classList.add('hellotext--webchat-message')
|
|
333
342
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
343
|
+
element.setAttribute('data-id', message.id)
|
|
344
|
+
|
|
345
|
+
if (createdAt) {
|
|
346
|
+
element.setAttribute('data-created-at', createdAt)
|
|
347
|
+
}
|
|
348
|
+
|
|
334
349
|
element.style.removeProperty('display')
|
|
335
350
|
|
|
336
351
|
element.querySelector('[data-body]').innerHTML = div.innerHTML
|
|
@@ -409,7 +424,7 @@ export default class extends Controller {
|
|
|
409
424
|
this.dismissTeaserForSession?.()
|
|
410
425
|
|
|
411
426
|
if (!this.onMobile) {
|
|
412
|
-
this.
|
|
427
|
+
this.focusComposeInput()
|
|
413
428
|
}
|
|
414
429
|
|
|
415
430
|
if (!this.scrolled) {
|
|
@@ -470,7 +485,7 @@ export default class extends Controller {
|
|
|
470
485
|
}
|
|
471
486
|
}
|
|
472
487
|
|
|
473
|
-
onMessageReceived(message) {
|
|
488
|
+
onMessageReceived(message, options = {}) {
|
|
474
489
|
const { id, body, attachments, teaser } = message
|
|
475
490
|
const createdAt = message.created_at || message.createdAt
|
|
476
491
|
|
|
@@ -479,19 +494,21 @@ export default class extends Controller {
|
|
|
479
494
|
this.hideTeaser?.()
|
|
480
495
|
|
|
481
496
|
if (message.carousel) {
|
|
482
|
-
return this.insertCarouselMessage(message)
|
|
497
|
+
return this.insertCarouselMessage(message, options)
|
|
483
498
|
}
|
|
484
499
|
|
|
485
500
|
const div = document.createElement('div')
|
|
486
501
|
div.innerHTML = body
|
|
487
502
|
|
|
488
503
|
const element = this.messageTemplateTarget.cloneNode(true)
|
|
504
|
+
element.classList.add('hellotext--webchat-message')
|
|
489
505
|
element.style.display = 'flex'
|
|
490
506
|
|
|
491
507
|
element.querySelector('[data-body]').innerHTML = div.innerHTML
|
|
492
508
|
|
|
493
509
|
element.setAttribute('data-id', id)
|
|
494
510
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
511
|
+
this.setMessageCreatedAt(element, createdAt)
|
|
495
512
|
this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt)
|
|
496
513
|
|
|
497
514
|
if (attachments) {
|
|
@@ -505,14 +522,16 @@ export default class extends Controller {
|
|
|
505
522
|
}
|
|
506
523
|
|
|
507
524
|
this.clearTypingIndicator()
|
|
508
|
-
this.
|
|
525
|
+
this.insertMessageElement(element)
|
|
509
526
|
|
|
510
527
|
Hellotext.eventEmitter.dispatch('webchat:message:received', {
|
|
511
528
|
...message,
|
|
512
529
|
body: element.querySelector('[data-body]').innerText,
|
|
513
530
|
})
|
|
514
531
|
|
|
515
|
-
|
|
532
|
+
if (options.scroll !== false) {
|
|
533
|
+
element.scrollIntoView({ behavior: 'smooth' })
|
|
534
|
+
}
|
|
516
535
|
|
|
517
536
|
this.updateMessageTeaser(teaser)
|
|
518
537
|
|
|
@@ -537,6 +556,70 @@ export default class extends Controller {
|
|
|
537
556
|
return !messageTargets.some(element => element.dataset.id === id)
|
|
538
557
|
}
|
|
539
558
|
|
|
559
|
+
captureCatchUpCursor() {
|
|
560
|
+
this.catchUpAfterMessageId = this.lastRenderedMessageId
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async catchUpMessages() {
|
|
564
|
+
const afterId = this.catchUpAfterMessageId
|
|
565
|
+
|
|
566
|
+
if (!afterId || this.fetchingCatchUpMessages) return
|
|
567
|
+
|
|
568
|
+
this.fetchingCatchUpMessages = true
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const response = await this.messagesAPI.catchUp(afterId)
|
|
572
|
+
const { messages = [] } = await response.json()
|
|
573
|
+
|
|
574
|
+
messages.forEach(message => this.onMessageReceived(message, { scroll: false }))
|
|
575
|
+
this.catchUpAfterMessageId = this.lastRenderedMessageId
|
|
576
|
+
} finally {
|
|
577
|
+
this.fetchingCatchUpMessages = false
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
get lastRenderedMessageId() {
|
|
582
|
+
const messages = this.persistedMessageElements
|
|
583
|
+
|
|
584
|
+
return messages[messages.length - 1]?.dataset.id || null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
get persistedMessageElements() {
|
|
588
|
+
return Array.from(
|
|
589
|
+
this.messagesContainerTarget.querySelectorAll('.hellotext--webchat-message[data-id]'),
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
setMessageCreatedAt(element, createdAt) {
|
|
594
|
+
if (createdAt) {
|
|
595
|
+
element.setAttribute('data-created-at', createdAt)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
insertMessageElement(element) {
|
|
600
|
+
const nextElement = this.nextMessageElementFor(element)
|
|
601
|
+
|
|
602
|
+
if (nextElement) {
|
|
603
|
+
this.messagesContainerTarget.insertBefore(element, nextElement)
|
|
604
|
+
} else {
|
|
605
|
+
this.messagesContainerTarget.appendChild(element)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
nextMessageElementFor(element) {
|
|
610
|
+
const createdAt = Date.parse(element.dataset.createdAt)
|
|
611
|
+
|
|
612
|
+
if (Number.isNaN(createdAt)) return null
|
|
613
|
+
|
|
614
|
+
return this.persistedMessageElements.find(messageElement => {
|
|
615
|
+
if (messageElement === element) return false
|
|
616
|
+
|
|
617
|
+
const messageCreatedAt = Date.parse(messageElement.dataset.createdAt)
|
|
618
|
+
|
|
619
|
+
return !Number.isNaN(messageCreatedAt) && messageCreatedAt > createdAt
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
540
623
|
updateMessageTeaser(teaser) {
|
|
541
624
|
this.messageTeaserValue = teaser
|
|
542
625
|
|
|
@@ -556,18 +639,23 @@ export default class extends Controller {
|
|
|
556
639
|
this.teaserTarget.classList.toggle('invisible', this.openValue)
|
|
557
640
|
}
|
|
558
641
|
|
|
559
|
-
insertCarouselMessage(message) {
|
|
642
|
+
insertCarouselMessage(message, options = {}) {
|
|
560
643
|
const html = message.html
|
|
644
|
+
const createdAt = message.created_at || message.createdAt
|
|
561
645
|
const element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild
|
|
562
646
|
|
|
647
|
+
element.classList.add('hellotext--webchat-message')
|
|
563
648
|
element.setAttribute('data-id', message.id)
|
|
564
649
|
element.setAttribute('data-hellotext--webchat-target', 'message')
|
|
650
|
+
this.setMessageCreatedAt(element, createdAt)
|
|
565
651
|
this.localizeMessageTimestamps(element)
|
|
566
652
|
|
|
567
653
|
this.clearTypingIndicator()
|
|
568
|
-
this.
|
|
654
|
+
this.insertMessageElement(element)
|
|
569
655
|
|
|
570
|
-
|
|
656
|
+
if (options.scroll !== false) {
|
|
657
|
+
element.scrollIntoView({ behavior: 'smooth' })
|
|
658
|
+
}
|
|
571
659
|
|
|
572
660
|
Hellotext.eventEmitter.dispatch('webchat:message:received', {
|
|
573
661
|
...message,
|
|
@@ -648,7 +736,10 @@ export default class extends Controller {
|
|
|
648
736
|
const data = await response.json()
|
|
649
737
|
|
|
650
738
|
this.dispatch('set:id', { target: element, detail: data.id })
|
|
651
|
-
this.localizeMessageTimestamp(
|
|
739
|
+
this.localizeMessageTimestamp(
|
|
740
|
+
element.querySelector('[data-message-timestamp]'),
|
|
741
|
+
data.created_at || data.createdAt,
|
|
742
|
+
)
|
|
652
743
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
653
744
|
|
|
654
745
|
const message = {
|
|
@@ -722,7 +813,10 @@ export default class extends Controller {
|
|
|
722
813
|
|
|
723
814
|
const data = await response.json()
|
|
724
815
|
element.setAttribute('data-id', data.id)
|
|
725
|
-
this.localizeMessageTimestamp(
|
|
816
|
+
this.localizeMessageTimestamp(
|
|
817
|
+
element.querySelector('[data-message-timestamp]'),
|
|
818
|
+
data.created_at || data.createdAt,
|
|
819
|
+
)
|
|
726
820
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
727
821
|
|
|
728
822
|
Hellotext.eventEmitter.dispatch('webchat:message:sent', {
|
|
@@ -819,7 +913,7 @@ export default class extends Controller {
|
|
|
819
913
|
this.attachmentContainerTarget.style.display = 'none'
|
|
820
914
|
this.errorMessageContainerTarget.style.display = 'none'
|
|
821
915
|
|
|
822
|
-
this.
|
|
916
|
+
this.focusComposeInput()
|
|
823
917
|
|
|
824
918
|
// Set up optimistic typing indicator BEFORE making the API call
|
|
825
919
|
// This prevents race conditions with server responses
|
|
@@ -842,7 +936,10 @@ export default class extends Controller {
|
|
|
842
936
|
const data = await response.json()
|
|
843
937
|
element.setAttribute('data-id', data.id)
|
|
844
938
|
message.id = data.id
|
|
845
|
-
this.localizeMessageTimestamp(
|
|
939
|
+
this.localizeMessageTimestamp(
|
|
940
|
+
element.querySelector('[data-message-timestamp]'),
|
|
941
|
+
data.created_at || data.createdAt,
|
|
942
|
+
)
|
|
846
943
|
this.clearRevealedOpeningSequenceMessageIds()
|
|
847
944
|
|
|
848
945
|
Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
|
|
@@ -892,19 +989,18 @@ export default class extends Controller {
|
|
|
892
989
|
|
|
893
990
|
if (!this.hasInputTarget || target.closest(ignoredSelector)) return
|
|
894
991
|
|
|
895
|
-
|
|
896
|
-
this.inputTarget.focus()
|
|
992
|
+
if (!this.focusComposeInput({ moveCursorToEnd: true })) return
|
|
897
993
|
|
|
898
|
-
|
|
899
|
-
const position = this.inputTarget.value.length
|
|
900
|
-
this.inputTarget.setSelectionRange(position, position)
|
|
901
|
-
}
|
|
994
|
+
event.preventDefault()
|
|
902
995
|
}
|
|
903
996
|
|
|
904
997
|
closePopoverFromHeader(event) {
|
|
905
998
|
const { target } = event
|
|
906
999
|
|
|
907
|
-
if (
|
|
1000
|
+
if (
|
|
1001
|
+
target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')
|
|
1002
|
+
)
|
|
1003
|
+
return
|
|
908
1004
|
|
|
909
1005
|
event.preventDefault()
|
|
910
1006
|
this.closePopover()
|
|
@@ -1085,7 +1181,7 @@ export default class extends Controller {
|
|
|
1085
1181
|
this.errorMessageContainerTarget.innerText = ''
|
|
1086
1182
|
|
|
1087
1183
|
newFiles.forEach(file => this.createAttachmentElement(file))
|
|
1088
|
-
this.
|
|
1184
|
+
this.focusComposeInput()
|
|
1089
1185
|
}
|
|
1090
1186
|
|
|
1091
1187
|
createAttachmentElement(file) {
|
|
@@ -1127,7 +1223,7 @@ export default class extends Controller {
|
|
|
1127
1223
|
this.attachmentInputTarget.value = ''
|
|
1128
1224
|
|
|
1129
1225
|
attachment.remove()
|
|
1130
|
-
this.
|
|
1226
|
+
this.focusComposeInput()
|
|
1131
1227
|
}
|
|
1132
1228
|
|
|
1133
1229
|
attachmentTargetDisconnected() {
|
|
@@ -1155,7 +1251,22 @@ export default class extends Controller {
|
|
|
1155
1251
|
this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end)
|
|
1156
1252
|
|
|
1157
1253
|
this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length
|
|
1254
|
+
this.focusComposeInput()
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
focusComposeInput({ moveCursorToEnd = false } = {}) {
|
|
1258
|
+
if (!this.shouldAutofocusCompose) return false
|
|
1259
|
+
if (this.hasInputTarget === false) return false
|
|
1260
|
+
if (this.hasInputTarget === undefined && !this.inputTarget) return false
|
|
1261
|
+
|
|
1158
1262
|
this.inputTarget.focus()
|
|
1263
|
+
|
|
1264
|
+
if (moveCursorToEnd && typeof this.inputTarget.selectionStart === 'number') {
|
|
1265
|
+
const position = this.inputTarget.value.length
|
|
1266
|
+
this.inputTarget.setSelectionRange(position, position)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return true
|
|
1159
1270
|
}
|
|
1160
1271
|
|
|
1161
1272
|
byteToMegabyte(bytes) {
|
|
@@ -1172,7 +1283,32 @@ export default class extends Controller {
|
|
|
1172
1283
|
)
|
|
1173
1284
|
}
|
|
1174
1285
|
|
|
1286
|
+
get shouldAutofocusCompose() {
|
|
1287
|
+
return !this.usesVirtualKeyboard
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
get usesVirtualKeyboard() {
|
|
1291
|
+
if (typeof navigator === 'undefined') return false
|
|
1292
|
+
|
|
1293
|
+
const userAgent = navigator.userAgent || ''
|
|
1294
|
+
const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
|
|
1295
|
+
const isMobileUserAgent = MOBILE_USER_AGENT_PATTERN.test(userAgent)
|
|
1296
|
+
const isUserAgentDataMobile = navigator.userAgentData?.mobile === true
|
|
1297
|
+
|
|
1298
|
+
return isMobileUserAgent || isIPadOS || isUserAgentDataMobile || this.hasTouchOnlyPointer
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
get hasTouchOnlyPointer() {
|
|
1302
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
1303
|
+
|
|
1304
|
+
return (
|
|
1305
|
+
window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches
|
|
1306
|
+
)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1175
1309
|
get onMobile() {
|
|
1310
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
1311
|
+
|
|
1176
1312
|
return window.matchMedia(`(max-width: ${this.fullScreenThresholdValue}px)`).matches
|
|
1177
1313
|
}
|
|
1178
1314
|
}
|