@hellotext/hellotext 2.4.3 → 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.
@@ -33,6 +33,11 @@ var MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
33
33
  hour: 'numeric',
34
34
  minute: '2-digit'
35
35
  };
36
+ var MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i;
37
+ var SCROLL_ISOLATION_EVENT_OPTIONS = {
38
+ capture: true,
39
+ passive: true
40
+ };
36
41
  var _default = /*#__PURE__*/function (_Controller) {
37
42
  _inherits(_default, _Controller);
38
43
  var _super = _createSuper(_default);
@@ -47,13 +52,19 @@ var _default = /*#__PURE__*/function (_Controller) {
47
52
  this.webChatChannel = new WebchatChannel(this.idValue, Hellotext.session, this.conversationIdValue);
48
53
  this.files = [];
49
54
  this.messageIds = new Set();
55
+ this.catchUpAfterMessageId = null;
56
+ this.fetchingCatchUpMessages = false;
50
57
  this.onMessageReceived = this.onMessageReceived.bind(this);
51
58
  this.onMessageReaction = this.onMessageReaction.bind(this);
52
59
  this.onTypingStart = this.onTypingStart.bind(this);
60
+ this.captureCatchUpCursor = this.captureCatchUpCursor.bind(this);
61
+ this.catchUpMessages = this.catchUpMessages.bind(this);
53
62
  this.onScroll = this.onScroll.bind(this);
54
63
  this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this);
55
64
  this.closePopoverOnEscape = this.closePopoverOnEscape.bind(this);
56
65
  this.broadcastChannel = new BroadcastChannel("hellotext--webchat--".concat(this.idValue));
66
+ this.webChatChannel.onDisconnect(this.captureCatchUpCursor);
67
+ this.webChatChannel.onReconnect(this.catchUpMessages);
57
68
  _get(_getPrototypeOf(_default.prototype), "initialize", this).call(this);
58
69
  }
59
70
  }, {
@@ -80,7 +91,10 @@ var _default = /*#__PURE__*/function (_Controller) {
80
91
  this.webChatChannel.onMessage(this.onMessageReceived);
81
92
  this.webChatChannel.onTypingStart(this.onTypingStart);
82
93
  this.webChatChannel.onReaction(this.onMessageReaction);
94
+ this.setupMessagesContainerScrollIsolation();
83
95
  this.messagesContainerTarget.addEventListener('scroll', this.onScroll);
96
+ this.messagesContainerTarget.addEventListener('wheel', this.stopHostScrollPropagation, SCROLL_ISOLATION_EVENT_OPTIONS);
97
+ this.messagesContainerTarget.addEventListener('touchmove', this.stopHostScrollPropagation, SCROLL_ISOLATION_EVENT_OPTIONS);
84
98
  if (this.shouldOpenOnMount) {
85
99
  this.openValue = true;
86
100
  }
@@ -99,6 +113,8 @@ var _default = /*#__PURE__*/function (_Controller) {
99
113
  this.teardownOpeningSequence();
100
114
  this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent);
101
115
  this.messagesContainerTarget.removeEventListener('scroll', this.onScroll);
116
+ this.messagesContainerTarget.removeEventListener('wheel', this.stopHostScrollPropagation, SCROLL_ISOLATION_EVENT_OPTIONS);
117
+ this.messagesContainerTarget.removeEventListener('touchmove', this.stopHostScrollPropagation, SCROLL_ISOLATION_EVENT_OPTIONS);
102
118
  window.removeEventListener('keydown', this.closePopoverOnEscape, true);
103
119
 
104
120
  // Clean up typing indicator timeouts
@@ -107,6 +123,21 @@ var _default = /*#__PURE__*/function (_Controller) {
107
123
  this.floatingUICleanup();
108
124
  _get(_getPrototypeOf(_default.prototype), "disconnect", this).call(this);
109
125
  }
126
+ }, {
127
+ key: "setupMessagesContainerScrollIsolation",
128
+ value: function setupMessagesContainerScrollIsolation() {
129
+ this.messagesContainerTarget.style.overscrollBehavior = 'contain';
130
+ this.messagesContainerTarget.style.webkitOverflowScrolling = 'touch';
131
+ this.messagesContainerTarget.style.touchAction = 'pan-y';
132
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent', '');
133
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent-wheel', '');
134
+ this.messagesContainerTarget.setAttribute('data-lenis-prevent-touch', '');
135
+ }
136
+ }, {
137
+ key: "stopHostScrollPropagation",
138
+ value: function stopHostScrollPropagation(event) {
139
+ event.stopPropagation();
140
+ }
110
141
  }, {
111
142
  key: "onTypingStart",
112
143
  value: function onTypingStart() {
@@ -236,7 +267,12 @@ var _default = /*#__PURE__*/function (_Controller) {
236
267
  var div = document.createElement('div');
237
268
  div.innerHTML = body;
238
269
  var element = this.messageTemplateTarget.cloneNode(true);
270
+ element.classList.add('hellotext--webchat-message');
239
271
  element.setAttribute('data-hellotext--webchat-target', 'message');
272
+ element.setAttribute('data-id', message.id);
273
+ if (createdAt) {
274
+ element.setAttribute('data-created-at', createdAt);
275
+ }
240
276
  element.style.removeProperty('display');
241
277
  element.querySelector('[data-body]').innerHTML = div.innerHTML;
242
278
  if (message.state === 'received') {
@@ -311,7 +347,7 @@ var _default = /*#__PURE__*/function (_Controller) {
311
347
  this.popoverTarget.classList.remove(...this.fadeOutClasses);
312
348
  (_this$dismissTeaserFo = this.dismissTeaserForSession) === null || _this$dismissTeaserFo === void 0 ? void 0 : _this$dismissTeaserFo.call(this);
313
349
  if (!this.onMobile) {
314
- this.inputTarget.focus();
350
+ this.focusComposeInput();
315
351
  }
316
352
  if (!this.scrolled) {
317
353
  requestAnimationFrame(() => {
@@ -368,6 +404,7 @@ var _default = /*#__PURE__*/function (_Controller) {
368
404
  key: "onMessageReceived",
369
405
  value: function onMessageReceived(message) {
370
406
  var _this$hideTeaser;
407
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
371
408
  var {
372
409
  id,
373
410
  body,
@@ -378,15 +415,17 @@ var _default = /*#__PURE__*/function (_Controller) {
378
415
  if (!this.claimMessageId(id)) return;
379
416
  (_this$hideTeaser = this.hideTeaser) === null || _this$hideTeaser === void 0 ? void 0 : _this$hideTeaser.call(this);
380
417
  if (message.carousel) {
381
- return this.insertCarouselMessage(message);
418
+ return this.insertCarouselMessage(message, options);
382
419
  }
383
420
  var div = document.createElement('div');
384
421
  div.innerHTML = body;
385
422
  var element = this.messageTemplateTarget.cloneNode(true);
423
+ element.classList.add('hellotext--webchat-message');
386
424
  element.style.display = 'flex';
387
425
  element.querySelector('[data-body]').innerHTML = div.innerHTML;
388
426
  element.setAttribute('data-id', id);
389
427
  element.setAttribute('data-hellotext--webchat-target', 'message');
428
+ this.setMessageCreatedAt(element, createdAt);
390
429
  this.localizeMessageTimestamp(element.querySelector('[data-message-timestamp]'), createdAt);
391
430
  if (attachments) {
392
431
  attachments.forEach(attachmentUrl => {
@@ -398,13 +437,15 @@ var _default = /*#__PURE__*/function (_Controller) {
398
437
  });
399
438
  }
400
439
  this.clearTypingIndicator();
401
- this.messagesContainerTarget.appendChild(element);
440
+ this.insertMessageElement(element);
402
441
  Hellotext.eventEmitter.dispatch('webchat:message:received', _objectSpread(_objectSpread({}, message), {}, {
403
442
  body: element.querySelector('[data-body]').innerText
404
443
  }));
405
- element.scrollIntoView({
406
- behavior: 'smooth'
407
- });
444
+ if (options.scroll !== false) {
445
+ element.scrollIntoView({
446
+ behavior: 'smooth'
447
+ });
448
+ }
408
449
  this.updateMessageTeaser(teaser);
409
450
  if (this.openValue) {
410
451
  this.messagesAPI.markAsSeen(id);
@@ -424,6 +465,76 @@ var _default = /*#__PURE__*/function (_Controller) {
424
465
  this.messageIds.add(id);
425
466
  return !messageTargets.some(element => element.dataset.id === id);
426
467
  }
468
+ }, {
469
+ key: "captureCatchUpCursor",
470
+ value: function captureCatchUpCursor() {
471
+ this.catchUpAfterMessageId = this.lastRenderedMessageId;
472
+ }
473
+ }, {
474
+ key: "catchUpMessages",
475
+ value: function () {
476
+ var _catchUpMessages = _asyncToGenerator(function* () {
477
+ var afterId = this.catchUpAfterMessageId;
478
+ if (!afterId || this.fetchingCatchUpMessages) return;
479
+ this.fetchingCatchUpMessages = true;
480
+ try {
481
+ var response = yield this.messagesAPI.catchUp(afterId);
482
+ var {
483
+ messages = []
484
+ } = yield response.json();
485
+ messages.forEach(message => this.onMessageReceived(message, {
486
+ scroll: false
487
+ }));
488
+ this.catchUpAfterMessageId = this.lastRenderedMessageId;
489
+ } finally {
490
+ this.fetchingCatchUpMessages = false;
491
+ }
492
+ });
493
+ function catchUpMessages() {
494
+ return _catchUpMessages.apply(this, arguments);
495
+ }
496
+ return catchUpMessages;
497
+ }()
498
+ }, {
499
+ key: "lastRenderedMessageId",
500
+ get: function get() {
501
+ var _messages;
502
+ var messages = this.persistedMessageElements;
503
+ return ((_messages = messages[messages.length - 1]) === null || _messages === void 0 ? void 0 : _messages.dataset.id) || null;
504
+ }
505
+ }, {
506
+ key: "persistedMessageElements",
507
+ get: function get() {
508
+ return Array.from(this.messagesContainerTarget.querySelectorAll('.hellotext--webchat-message[data-id]'));
509
+ }
510
+ }, {
511
+ key: "setMessageCreatedAt",
512
+ value: function setMessageCreatedAt(element, createdAt) {
513
+ if (createdAt) {
514
+ element.setAttribute('data-created-at', createdAt);
515
+ }
516
+ }
517
+ }, {
518
+ key: "insertMessageElement",
519
+ value: function insertMessageElement(element) {
520
+ var nextElement = this.nextMessageElementFor(element);
521
+ if (nextElement) {
522
+ this.messagesContainerTarget.insertBefore(element, nextElement);
523
+ } else {
524
+ this.messagesContainerTarget.appendChild(element);
525
+ }
526
+ }
527
+ }, {
528
+ key: "nextMessageElementFor",
529
+ value: function nextMessageElementFor(element) {
530
+ var createdAt = Date.parse(element.dataset.createdAt);
531
+ if (Number.isNaN(createdAt)) return null;
532
+ return this.persistedMessageElements.find(messageElement => {
533
+ if (messageElement === element) return false;
534
+ var messageCreatedAt = Date.parse(messageElement.dataset.createdAt);
535
+ return !Number.isNaN(messageCreatedAt) && messageCreatedAt > createdAt;
536
+ });
537
+ }
427
538
  }, {
428
539
  key: "updateMessageTeaser",
429
540
  value: function updateMessageTeaser(teaser) {
@@ -440,16 +551,22 @@ var _default = /*#__PURE__*/function (_Controller) {
440
551
  key: "insertCarouselMessage",
441
552
  value: function insertCarouselMessage(message) {
442
553
  var _element$querySelecto;
554
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
443
555
  var html = message.html;
556
+ var createdAt = message.created_at || message.createdAt;
444
557
  var element = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild;
558
+ element.classList.add('hellotext--webchat-message');
445
559
  element.setAttribute('data-id', message.id);
446
560
  element.setAttribute('data-hellotext--webchat-target', 'message');
561
+ this.setMessageCreatedAt(element, createdAt);
447
562
  this.localizeMessageTimestamps(element);
448
563
  this.clearTypingIndicator();
449
- this.messagesContainerTarget.appendChild(element);
450
- element.scrollIntoView({
451
- behavior: 'smooth'
452
- });
564
+ this.insertMessageElement(element);
565
+ if (options.scroll !== false) {
566
+ element.scrollIntoView({
567
+ behavior: 'smooth'
568
+ });
569
+ }
453
570
  Hellotext.eventEmitter.dispatch('webchat:message:received', _objectSpread(_objectSpread({}, message), {}, {
454
571
  body: ((_element$querySelecto = element.querySelector('[data-body]')) === null || _element$querySelecto === void 0 ? void 0 : _element$querySelecto.innerText) || ''
455
572
  }));
@@ -681,7 +798,7 @@ var _default = /*#__PURE__*/function (_Controller) {
681
798
  this.attachmentContainerTarget.innerHTML = '';
682
799
  this.attachmentContainerTarget.style.display = 'none';
683
800
  this.errorMessageContainerTarget.style.display = 'none';
684
- this.inputTarget.focus();
801
+ this.focusComposeInput();
685
802
 
686
803
  // Set up optimistic typing indicator BEFORE making the API call
687
804
  // This prevents race conditions with server responses
@@ -739,12 +856,10 @@ var _default = /*#__PURE__*/function (_Controller) {
739
856
  } = event;
740
857
  var ignoredSelector = ['button', 'a', 'input', 'textarea', 'select', 'label', '[role="button"]', 'em-emoji-picker', '[data-hellotext--webchat--emoji-target~="popover"]', '[data-controller~="hellotext--webchat--emoji"]'].join(', ');
741
858
  if (!this.hasInputTarget || target.closest(ignoredSelector)) return;
859
+ if (!this.focusComposeInput({
860
+ moveCursorToEnd: true
861
+ })) return;
742
862
  event.preventDefault();
743
- this.inputTarget.focus();
744
- if (typeof this.inputTarget.selectionStart === 'number') {
745
- var position = this.inputTarget.value.length;
746
- this.inputTarget.setSelectionRange(position, position);
747
- }
748
863
  }
749
864
  }, {
750
865
  key: "closePopoverFromHeader",
@@ -907,7 +1022,7 @@ var _default = /*#__PURE__*/function (_Controller) {
907
1022
  this.files = [...this.files, ...newFiles];
908
1023
  this.errorMessageContainerTarget.innerText = '';
909
1024
  newFiles.forEach(file => this.createAttachmentElement(file));
910
- this.inputTarget.focus();
1025
+ this.focusComposeInput();
911
1026
  }
912
1027
  }, {
913
1028
  key: "createAttachmentElement",
@@ -943,7 +1058,7 @@ var _default = /*#__PURE__*/function (_Controller) {
943
1058
  this.files = this.files.filter(file => file.name !== attachment.dataset.name);
944
1059
  this.attachmentInputTarget.value = '';
945
1060
  attachment.remove();
946
- this.inputTarget.focus();
1061
+ this.focusComposeInput();
947
1062
  }
948
1063
  }, {
949
1064
  key: "attachmentTargetDisconnected",
@@ -973,7 +1088,23 @@ var _default = /*#__PURE__*/function (_Controller) {
973
1088
  var end = this.inputTarget.selectionEnd;
974
1089
  this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end);
975
1090
  this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length;
1091
+ this.focusComposeInput();
1092
+ }
1093
+ }, {
1094
+ key: "focusComposeInput",
1095
+ value: function focusComposeInput() {
1096
+ var {
1097
+ moveCursorToEnd = false
1098
+ } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
1099
+ if (!this.shouldAutofocusCompose) return false;
1100
+ if (this.hasInputTarget === false) return false;
1101
+ if (this.hasInputTarget === undefined && !this.inputTarget) return false;
976
1102
  this.inputTarget.focus();
1103
+ if (moveCursorToEnd && typeof this.inputTarget.selectionStart === 'number') {
1104
+ var position = this.inputTarget.value.length;
1105
+ this.inputTarget.setSelectionRange(position, position);
1106
+ }
1107
+ return true;
977
1108
  }
978
1109
  }, {
979
1110
  key: "byteToMegabyte",
@@ -992,9 +1123,32 @@ var _default = /*#__PURE__*/function (_Controller) {
992
1123
  get: function get() {
993
1124
  return localStorage.getItem("hellotext--webchat--".concat(this.idValue)) === 'opened' && !this.onMobile;
994
1125
  }
1126
+ }, {
1127
+ key: "shouldAutofocusCompose",
1128
+ get: function get() {
1129
+ return !this.usesVirtualKeyboard;
1130
+ }
1131
+ }, {
1132
+ key: "usesVirtualKeyboard",
1133
+ get: function get() {
1134
+ var _navigator$userAgentD;
1135
+ if (typeof navigator === 'undefined') return false;
1136
+ var userAgent = navigator.userAgent || '';
1137
+ var isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
1138
+ var isMobileUserAgent = MOBILE_USER_AGENT_PATTERN.test(userAgent);
1139
+ var isUserAgentDataMobile = ((_navigator$userAgentD = navigator.userAgentData) === null || _navigator$userAgentD === void 0 ? void 0 : _navigator$userAgentD.mobile) === true;
1140
+ return isMobileUserAgent || isIPadOS || isUserAgentDataMobile || this.hasTouchOnlyPointer;
1141
+ }
1142
+ }, {
1143
+ key: "hasTouchOnlyPointer",
1144
+ get: function get() {
1145
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
1146
+ return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches;
1147
+ }
995
1148
  }, {
996
1149
  key: "onMobile",
997
1150
  get: function get() {
1151
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
998
1152
  return window.matchMedia("(max-width: ".concat(this.fullScreenThresholdValue, "px)")).matches;
999
1153
  }
1000
1154
  }], [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellotext/hellotext",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "Hellotext JavaScript Client",
5
5
  "source": "src/index.js",
6
6
  "main": "lib/index.cjs",
@@ -25,6 +25,13 @@ class WebchatMessagesAPI {
25
25
  })
26
26
  }
27
27
 
28
+ catchUp(afterId) {
29
+ return this.index({
30
+ after_id: afterId,
31
+ session: Hellotext.session,
32
+ })
33
+ }
34
+
28
35
  async create(formData) {
29
36
  const response = await fetch(this.url, {
30
37
  method: 'POST',
@@ -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',