@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.
- 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 +167 -22
- package/lib/controllers/webchat_controller.js +172 -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 +193 -21
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
406
|
-
|
|
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.
|
|
450
|
-
|
|
451
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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',
|