@hellotext/hellotext 2.4.4 → 2.4.51

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.
@@ -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
- if (this.webSocket.readyState === WebSocket.OPEN) {
14
- this.webSocket.send(JSON.stringify(payload))
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
- this.webSocket.addEventListener('open', () => {
17
- this.webSocket.send(JSON.stringify(payload))
33
+ socket.addEventListener('open', () => {
34
+ socket.send(message)
18
35
  })
19
36
  }
20
37
  }
21
38
 
22
39
  onMessage(callback) {
23
- this.webSocket.addEventListener('message', event => {
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
- if (!ApplicationChannel.webSocket) {
37
- return (ApplicationChannel.webSocket = new WebSocket(Configuration.actionCableUrl))
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
- return ApplicationChannel.webSocket
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',
@@ -13,6 +13,11 @@ export default class extends Controller {
13
13
 
14
14
  connect() {
15
15
  this.updateFades()
16
+ this.observeContainerSize()
17
+ }
18
+
19
+ disconnect() {
20
+ this.resizeObserver?.disconnect()
16
21
  }
17
22
 
18
23
  setId({ detail: id }) {
@@ -195,6 +200,13 @@ export default class extends Controller {
195
200
  return Number.isFinite(this.pageStartOffsetValue) ? this.pageStartOffsetValue : 0
196
201
  }
197
202
 
203
+ observeContainerSize() {
204
+ if (!this.hasCarouselContainerTarget || !window.ResizeObserver) return
205
+
206
+ this.resizeObserver = new ResizeObserver(() => this.updateFades())
207
+ this.resizeObserver.observe(this.carouselContainerTarget)
208
+ }
209
+
198
210
  updateFades() {
199
211
  if (!this.hasCarouselContainerTarget) return
200
212
 
@@ -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.inputTarget.focus()
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.messagesContainerTarget.appendChild(element)
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
- element.scrollIntoView({ behavior: 'smooth' })
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.messagesContainerTarget.appendChild(element)
654
+ this.insertMessageElement(element)
569
655
 
570
- element.scrollIntoView({ behavior: 'smooth' })
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(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
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(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
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.inputTarget.focus()
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(element.querySelector('[data-message-timestamp]'), data.created_at || data.createdAt)
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
- event.preventDefault()
896
- this.inputTarget.focus()
992
+ if (!this.focusComposeInput({ moveCursorToEnd: true })) return
897
993
 
898
- if (typeof this.inputTarget.selectionStart === 'number') {
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 (target.closest('.hellotext--webchat-header-channel-button, .hellotext--webchat-close-button')) return
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.inputTarget.focus()
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.inputTarget.focus()
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
  }