@nyaruka/temba-components 0.161.0 → 0.162.0
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/CHANGELOG.md +6 -0
- package/dist/locales/es.js +2 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +2 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +2 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +465 -465
- package/dist/temba-components.js.map +1 -1
- package/package.json +3 -2
- package/src/display/Chat.ts +16 -0
- package/src/display/TembaUser.ts +7 -0
- package/src/live/ContactChat.ts +120 -78
- package/src/live/SocketService.ts +158 -0
- package/src/locales/es.ts +2 -0
- package/src/locales/fr.ts +2 -0
- package/src/locales/pt.ts +2 -0
- package/src/store/Store.ts +18 -0
- package/xliff/es.xlf +6 -0
- package/xliff/fr.xlf +6 -0
- package/xliff/pt.xlf +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyaruka/temba-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.0",
|
|
4
4
|
"description": "Web components to support rapidpro and related projects",
|
|
5
5
|
"author": "Nyaruka <code@nyaruka.coim>",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@jsplumb/browser-ui": "^6.2.10",
|
|
40
40
|
"@lit/localize": "^0.12.2",
|
|
41
41
|
"@open-wc/lit-helpers": "^0.7.0",
|
|
42
|
+
"centrifuge": "^5.7.0",
|
|
42
43
|
"chart.js": "^4.5.1",
|
|
43
44
|
"chartjs-adapter-luxon": "^1.3.1",
|
|
44
45
|
"chartjs-plugin-datalabels": "^2.2.0",
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"@rollup/plugin-terser": "^0.4.4",
|
|
69
70
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
70
71
|
"@types/leaflet": "1.4.4",
|
|
72
|
+
"@types/mocha": "^10.0.0",
|
|
71
73
|
"@types/node": "22.13.0",
|
|
72
74
|
"@types/remarkable": "^2.0.1",
|
|
73
75
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
|
@@ -107,7 +109,6 @@
|
|
|
107
109
|
"sinon": "^21.0.0",
|
|
108
110
|
"svgstore": "^3.0.1",
|
|
109
111
|
"tslib": "2.8.1",
|
|
110
|
-
"@types/mocha": "^10.0.0",
|
|
111
112
|
"typescript": "5.9.3",
|
|
112
113
|
"yargs": "^17.7.2"
|
|
113
114
|
},
|
package/src/display/Chat.ts
CHANGED
|
@@ -838,6 +838,10 @@ export class Chat extends RapidElement {
|
|
|
838
838
|
private msgMap = new Map<string, ContactEvent>();
|
|
839
839
|
private metadataCache = new Map<string, ContactEvent>();
|
|
840
840
|
|
|
841
|
+
// bumped on reset so deferred addMessages work from before a reset is
|
|
842
|
+
// discarded rather than re-merged into the fresh view
|
|
843
|
+
private resetGeneration = 0;
|
|
844
|
+
|
|
841
845
|
public firstUpdated(
|
|
842
846
|
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
843
847
|
): void {
|
|
@@ -858,10 +862,16 @@ export class Chat extends RapidElement {
|
|
|
858
862
|
startTime = new Date();
|
|
859
863
|
}
|
|
860
864
|
|
|
865
|
+
const generation = this.resetGeneration;
|
|
861
866
|
const elapsed = new Date().getTime() - startTime.getTime();
|
|
862
867
|
window.setTimeout(
|
|
863
868
|
() => {
|
|
864
869
|
this.fetching = false;
|
|
870
|
+
|
|
871
|
+
// the chat was reset while we were waiting, our messages are stale
|
|
872
|
+
if (generation !== this.resetGeneration) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
865
875
|
// first add messages to the map
|
|
866
876
|
const newMessages = [];
|
|
867
877
|
for (const m of messages) {
|
|
@@ -905,6 +915,10 @@ export class Chat extends RapidElement {
|
|
|
905
915
|
}
|
|
906
916
|
|
|
907
917
|
window.setTimeout(() => {
|
|
918
|
+
if (generation !== this.resetGeneration) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
908
922
|
// when appending (new messages at bottom), adjust scroll to maintain visible content
|
|
909
923
|
// with column-reverse, new content at bottom increases scrollHeight
|
|
910
924
|
if (append && (isScrolledAway || maintainScroll)) {
|
|
@@ -1486,7 +1500,9 @@ export class Chat extends RapidElement {
|
|
|
1486
1500
|
}
|
|
1487
1501
|
|
|
1488
1502
|
public reset() {
|
|
1503
|
+
this.resetGeneration++;
|
|
1489
1504
|
this.msgMap.clear();
|
|
1505
|
+
this.metadataCache.clear();
|
|
1490
1506
|
this.messageGroups = [];
|
|
1491
1507
|
this.hideBottomScroll = true;
|
|
1492
1508
|
this.hideTopScroll = true;
|
package/src/display/TembaUser.ts
CHANGED
|
@@ -116,6 +116,13 @@ export class TembaUser extends RapidElement {
|
|
|
116
116
|
if (changed.has('avatar')) {
|
|
117
117
|
if (this.avatar) {
|
|
118
118
|
this.bgimage = `url('${this.avatar}') center / contain no-repeat`;
|
|
119
|
+
} else {
|
|
120
|
+
// clearing the avatar must also clear the image, otherwise a reused
|
|
121
|
+
// element keeps showing the previous user's avatar - fall back to the
|
|
122
|
+
// system default or the initials/contact-icon branch
|
|
123
|
+
this.bgimage = this.system
|
|
124
|
+
? `url('${DEFAULT_AVATAR}') center / contain no-repeat`
|
|
125
|
+
: null;
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
}
|
package/src/live/ContactChat.ts
CHANGED
|
@@ -36,14 +36,7 @@ import {
|
|
|
36
36
|
renderTicketAction,
|
|
37
37
|
renderTicketAssigneeChanged
|
|
38
38
|
} from '../events/eventRenderers';
|
|
39
|
-
|
|
40
|
-
/*
|
|
41
|
-
export const SCROLL_THRESHOLD = 100;
|
|
42
|
-
export const SIMULATED_WEB_SLOWNESS = 0;
|
|
43
|
-
export const MAX_CHAT_REFRESH = 10000;
|
|
44
|
-
export const MIN_CHAT_REFRESH = 500;
|
|
45
|
-
export const BODY_SNIPPET_LENGTH = 250;
|
|
46
|
-
*/
|
|
39
|
+
import { subscribeToSocket, SocketSubscription } from './SocketService';
|
|
47
40
|
|
|
48
41
|
// re-export for backwards compatibility
|
|
49
42
|
export { renderTicketAction, renderTicketAssigneeChanged };
|
|
@@ -527,11 +520,11 @@ export class ContactChat extends ContactStoreElement {
|
|
|
527
520
|
|
|
528
521
|
ticket = null;
|
|
529
522
|
beforeUUID: string = null; // for scrolling back through history
|
|
530
|
-
afterUUID: string = null; // for
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
523
|
+
afterUUID: string = null; // newest event seen, for catch-up fetches
|
|
524
|
+
|
|
525
|
+
// live socket subscriptions by channel for the current contact (and ticket)
|
|
526
|
+
private subscriptions = new Map<string, SocketSubscription>();
|
|
527
|
+
private fetchingMissed = false;
|
|
535
528
|
|
|
536
529
|
constructor() {
|
|
537
530
|
super();
|
|
@@ -562,28 +555,22 @@ export class ContactChat extends ContactStoreElement {
|
|
|
562
555
|
public connectedCallback() {
|
|
563
556
|
super.connectedCallback();
|
|
564
557
|
this.chat = this.shadowRoot.querySelector('temba-chat');
|
|
558
|
+
this.updateSubscriptions();
|
|
565
559
|
}
|
|
566
560
|
|
|
567
561
|
public disconnectedCallback() {
|
|
568
562
|
super.disconnectedCallback();
|
|
569
|
-
|
|
570
|
-
clearInterval(this.refreshId);
|
|
571
|
-
}
|
|
563
|
+
this.unsubscribeAll();
|
|
572
564
|
}
|
|
573
565
|
|
|
574
566
|
public updated(changedProperties: Map<string, any>) {
|
|
575
567
|
super.updated(changedProperties);
|
|
576
568
|
|
|
577
|
-
// if we don't have an endpoint infer one
|
|
578
569
|
if (
|
|
579
|
-
changedProperties.has('
|
|
580
|
-
changedProperties.has('
|
|
570
|
+
changedProperties.has('currentContact') ||
|
|
571
|
+
changedProperties.has('currentTicket')
|
|
581
572
|
) {
|
|
582
|
-
|
|
583
|
-
if (this.refreshId) {
|
|
584
|
-
clearTimeout(this.refreshId);
|
|
585
|
-
this.refreshId = null;
|
|
586
|
-
}
|
|
573
|
+
this.updateSubscriptions();
|
|
587
574
|
}
|
|
588
575
|
|
|
589
576
|
if (changedProperties.has('currentContact') && this.currentContact) {
|
|
@@ -594,12 +581,74 @@ export class ContactChat extends ContactStoreElement {
|
|
|
594
581
|
) {
|
|
595
582
|
this.reset();
|
|
596
583
|
} else {
|
|
597
|
-
|
|
584
|
+
this.fetchMissedEvents();
|
|
598
585
|
}
|
|
599
586
|
this.fetchPreviousMessages();
|
|
600
587
|
}
|
|
601
588
|
}
|
|
602
589
|
|
|
590
|
+
private unsubscribeAll() {
|
|
591
|
+
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
|
592
|
+
this.subscriptions.clear();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Keeps our socket subscriptions in sync with the current contact and
|
|
597
|
+
* ticket. New events arrive as they happen on "history:<contact-uuid>";
|
|
598
|
+
* ticket detail events (notes, assignment, topic) only arrive on
|
|
599
|
+
* "history:<contact-uuid>:<ticket-uuid>" so we subscribe to both when
|
|
600
|
+
* viewing a ticket. Only the channels that actually changed are touched,
|
|
601
|
+
* so switching tickets on the same contact leaves the contact channel
|
|
602
|
+
* continuously subscribed with no gap in delivery.
|
|
603
|
+
*/
|
|
604
|
+
private updateSubscriptions() {
|
|
605
|
+
const channels = new Set<string>();
|
|
606
|
+
if (this.isConnected && this.currentContact) {
|
|
607
|
+
channels.add(`history:${this.currentContact.uuid}`);
|
|
608
|
+
if (this.currentTicket) {
|
|
609
|
+
channels.add(
|
|
610
|
+
`history:${this.currentContact.uuid}:${this.currentTicket.uuid}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// drop subscriptions we no longer need
|
|
616
|
+
this.subscriptions.forEach((sub, channel) => {
|
|
617
|
+
if (!channels.has(channel)) {
|
|
618
|
+
sub.unsubscribe();
|
|
619
|
+
this.subscriptions.delete(channel);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// add any new ones
|
|
624
|
+
channels.forEach((channel) => {
|
|
625
|
+
if (!this.subscriptions.has(channel)) {
|
|
626
|
+
this.subscriptions.set(
|
|
627
|
+
channel,
|
|
628
|
+
subscribeToSocket(
|
|
629
|
+
channel,
|
|
630
|
+
(data: any) => this.handleSocketEvent(data),
|
|
631
|
+
// on every (re)subscribe fetch anything we might have missed
|
|
632
|
+
() => this.fetchMissedEvents()
|
|
633
|
+
)
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private handleSocketEvent(event: any) {
|
|
640
|
+
// while searching we're viewing an arbitrary point in history, new
|
|
641
|
+
// events will be picked up by the refetch when search closes
|
|
642
|
+
if (!this.chat || this.searchMode || !this.currentContact) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const messages = this.createMessages({ events: [event], next: null });
|
|
647
|
+
if (messages.length > 0) {
|
|
648
|
+
this.chat.addMessages(messages, null, true);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
603
652
|
private reset() {
|
|
604
653
|
if (this.chat) {
|
|
605
654
|
this.chat.reset();
|
|
@@ -607,10 +656,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
607
656
|
this.ticket = null;
|
|
608
657
|
this.beforeUUID = null;
|
|
609
658
|
this.afterUUID = null;
|
|
610
|
-
this.
|
|
611
|
-
this.polling = false;
|
|
612
|
-
this.pollingInterval = 2000;
|
|
613
|
-
this.lastFetchTime = null;
|
|
659
|
+
this.fetchingMissed = false;
|
|
614
660
|
|
|
615
661
|
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
|
|
616
662
|
if (compose) {
|
|
@@ -628,11 +674,6 @@ export class ContactChat extends ContactStoreElement {
|
|
|
628
674
|
this.searchIndex = -1;
|
|
629
675
|
this.searchLoading = false;
|
|
630
676
|
this.searchNoResults = false;
|
|
631
|
-
// stop polling while searching
|
|
632
|
-
if (this.refreshId) {
|
|
633
|
-
clearTimeout(this.refreshId);
|
|
634
|
-
this.refreshId = null;
|
|
635
|
-
}
|
|
636
677
|
window.setTimeout(() => {
|
|
637
678
|
const input = this.shadowRoot.querySelector('.search-input') as any;
|
|
638
679
|
if (input) {
|
|
@@ -953,10 +994,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
953
994
|
if (response.status < 400) {
|
|
954
995
|
const event = response.json.event;
|
|
955
996
|
event.created_on = new Date(event.created_on);
|
|
997
|
+
this.resolveUserAvatar(event);
|
|
956
998
|
this.chat.addMessages([event], null, true);
|
|
957
|
-
// reset polling interval to 2 seconds after sending a message
|
|
958
|
-
this.pollingInterval = 2000;
|
|
959
|
-
this.checkForNewMessages();
|
|
960
999
|
composeEle.reset();
|
|
961
1000
|
this.fireCustomEvent(CustomEventType.MessageSent, {
|
|
962
1001
|
msg: payload,
|
|
@@ -978,25 +1017,6 @@ export class ContactChat extends ContactStoreElement {
|
|
|
978
1017
|
return null;
|
|
979
1018
|
}
|
|
980
1019
|
|
|
981
|
-
private scheduleRefresh(hasNewEvents = false) {
|
|
982
|
-
if (this.refreshId) {
|
|
983
|
-
clearTimeout(this.refreshId);
|
|
984
|
-
this.refreshId = null;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// reset to 2 seconds if we received new events
|
|
988
|
-
if (hasNewEvents) {
|
|
989
|
-
this.pollingInterval = 2000;
|
|
990
|
-
} else {
|
|
991
|
-
// increase interval by 1 second up to max of 15 seconds
|
|
992
|
-
this.pollingInterval = Math.min(this.pollingInterval + 1000, 15000);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
this.refreshId = setTimeout(() => {
|
|
996
|
-
this.checkForNewMessages();
|
|
997
|
-
}, this.pollingInterval);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
1020
|
public prerender(event: ContactEvent) {
|
|
1001
1021
|
// use the unified renderEvent function with isSimulation = false
|
|
1002
1022
|
const rendered = renderEvent(event, false);
|
|
@@ -1009,10 +1029,27 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1009
1029
|
}
|
|
1010
1030
|
}
|
|
1011
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Keeps the store's avatar cache fresh from server-hydrated user refs and
|
|
1034
|
+
* fills in refs that arrive without one - events published over sockets
|
|
1035
|
+
* carry only a user's uuid and name.
|
|
1036
|
+
*/
|
|
1037
|
+
private resolveUserAvatar(event: any) {
|
|
1038
|
+
const user = event._user;
|
|
1039
|
+
if (user && user.uuid && this.store) {
|
|
1040
|
+
if (user.avatar) {
|
|
1041
|
+
this.store.setUserAvatar(user.uuid, user.avatar);
|
|
1042
|
+
} else {
|
|
1043
|
+
user.avatar = this.store.getUserAvatar(user.uuid);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1012
1048
|
private createMessages(page: ContactHistoryPage): ContactEvent[] {
|
|
1013
1049
|
if (page.events) {
|
|
1014
1050
|
const messages: ContactEvent[] = [];
|
|
1015
1051
|
page.events.forEach((event) => {
|
|
1052
|
+
this.resolveUserAvatar(event);
|
|
1016
1053
|
// track the UUID of the newest event for polling
|
|
1017
1054
|
if (
|
|
1018
1055
|
!this.afterUUID ||
|
|
@@ -1047,40 +1084,48 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1047
1084
|
return [];
|
|
1048
1085
|
}
|
|
1049
1086
|
|
|
1050
|
-
|
|
1087
|
+
/**
|
|
1088
|
+
* Fetches any events newer than the last one we've seen. New events
|
|
1089
|
+
* normally arrive over the socket, so this is only needed to cover gaps -
|
|
1090
|
+
* events published between our initial history fetch and the subscription
|
|
1091
|
+
* becoming active, or while a dropped connection was reconnecting.
|
|
1092
|
+
*/
|
|
1093
|
+
private fetchMissedEvents() {
|
|
1051
1094
|
// we are already working on it or in search mode
|
|
1052
|
-
if (this.
|
|
1095
|
+
if (this.fetchingMissed || this.searchMode) {
|
|
1053
1096
|
return;
|
|
1054
1097
|
}
|
|
1055
1098
|
|
|
1056
1099
|
const chat = this.chat;
|
|
1057
1100
|
if (this.currentContact && this.afterUUID) {
|
|
1058
|
-
this.
|
|
1059
|
-
this.lastFetchTime = Date.now();
|
|
1101
|
+
this.fetchingMissed = true;
|
|
1060
1102
|
const endpoint = this.getEndpoint();
|
|
1061
1103
|
if (!endpoint) {
|
|
1104
|
+
this.fetchingMissed = false;
|
|
1062
1105
|
return;
|
|
1063
1106
|
}
|
|
1064
1107
|
|
|
1065
1108
|
const fetchContact = this.currentContact.uuid;
|
|
1109
|
+
const fetchTicket = this.currentTicket?.uuid;
|
|
1066
1110
|
|
|
1067
|
-
fetchContactHistory(
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1111
|
+
fetchContactHistory(endpoint, fetchTicket, null, this.afterUUID).then(
|
|
1112
|
+
(page: ContactHistoryPage) => {
|
|
1113
|
+
this.fetchingMissed = false;
|
|
1114
|
+
|
|
1115
|
+
// things may have changed while we were fetching
|
|
1116
|
+
if (
|
|
1117
|
+
this.searchMode ||
|
|
1118
|
+
fetchContact !== this.currentContact?.uuid ||
|
|
1119
|
+
fetchTicket !== this.currentTicket?.uuid
|
|
1120
|
+
) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const messages = this.createMessages(page);
|
|
1125
|
+
messages.reverse();
|
|
1077
1126
|
chat.addMessages(messages, null, true);
|
|
1078
|
-
this.polling = false;
|
|
1079
|
-
this.scheduleRefresh(hasNewEvents);
|
|
1080
|
-
} else {
|
|
1081
|
-
this.polling = false;
|
|
1082
1127
|
}
|
|
1083
|
-
|
|
1128
|
+
);
|
|
1084
1129
|
}
|
|
1085
1130
|
}
|
|
1086
1131
|
|
|
@@ -1130,9 +1175,6 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1130
1175
|
}
|
|
1131
1176
|
|
|
1132
1177
|
chat.addMessages(messages);
|
|
1133
|
-
if (!this.searchMode) {
|
|
1134
|
-
this.scheduleRefresh();
|
|
1135
|
-
}
|
|
1136
1178
|
});
|
|
1137
1179
|
}
|
|
1138
1180
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Centrifuge, Subscription, SubscriptionState } from 'centrifuge';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Access to our realtime messaging socket (centrifugo). The server lives
|
|
5
|
+
* behind the same origin at /ws/connect and authenticates connections with
|
|
6
|
+
* the browser's session cookie (via a server-side connect proxy), so no
|
|
7
|
+
* token handling is needed here.
|
|
8
|
+
*
|
|
9
|
+
* The connection is owned by a SocketManager which is page-scoped - it hangs
|
|
10
|
+
* off `window` rather than any component, so it survives components mounting
|
|
11
|
+
* and unmounting, and vanilla js on the containing page can share the same
|
|
12
|
+
* connection:
|
|
13
|
+
*
|
|
14
|
+
* const sub = window.sockets.subscribe('notifications:<org>:<user>', (event) => {
|
|
15
|
+
* ...
|
|
16
|
+
* });
|
|
17
|
+
* sub.unsubscribe();
|
|
18
|
+
*
|
|
19
|
+
* Any number of subscribers (components or page js) can watch the same
|
|
20
|
+
* channel - the underlying centrifugo subscription is created on first use
|
|
21
|
+
* and torn down when the last subscriber leaves. The connection itself stays
|
|
22
|
+
* open for the life of the page. Each published event arrives as raw JSON.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SocketSubscription {
|
|
26
|
+
unsubscribe(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PublicationHandler = (data: any) => void;
|
|
30
|
+
|
|
31
|
+
export interface SocketProvider {
|
|
32
|
+
subscribe(
|
|
33
|
+
channel: string,
|
|
34
|
+
onPublication: PublicationHandler,
|
|
35
|
+
onSubscribed?: () => void
|
|
36
|
+
): SocketSubscription;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ChannelEntry {
|
|
40
|
+
sub: Subscription;
|
|
41
|
+
count: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SocketManager implements SocketProvider {
|
|
45
|
+
private socket: Centrifuge = null;
|
|
46
|
+
private channels = new Map<string, ChannelEntry>();
|
|
47
|
+
private createSocket: () => Centrifuge;
|
|
48
|
+
|
|
49
|
+
constructor(createSocket?: () => Centrifuge) {
|
|
50
|
+
this.createSocket =
|
|
51
|
+
createSocket ||
|
|
52
|
+
(() => {
|
|
53
|
+
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
54
|
+
const socket = new Centrifuge(
|
|
55
|
+
`${protocol}://${window.location.host}/ws/connect`
|
|
56
|
+
);
|
|
57
|
+
socket.connect();
|
|
58
|
+
return socket;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public subscribe(
|
|
63
|
+
channel: string,
|
|
64
|
+
onPublication: PublicationHandler,
|
|
65
|
+
onSubscribed?: () => void
|
|
66
|
+
): SocketSubscription {
|
|
67
|
+
if (!this.socket) {
|
|
68
|
+
this.socket = this.createSocket();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let entry = this.channels.get(channel);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
entry = {
|
|
74
|
+
sub:
|
|
75
|
+
this.socket.getSubscription(channel) ||
|
|
76
|
+
this.socket.newSubscription(channel),
|
|
77
|
+
count: 0
|
|
78
|
+
};
|
|
79
|
+
this.channels.set(channel, entry);
|
|
80
|
+
entry.sub.subscribe();
|
|
81
|
+
}
|
|
82
|
+
entry.count++;
|
|
83
|
+
|
|
84
|
+
const sub = entry.sub;
|
|
85
|
+
const pubHandler = (ctx: { data: any }) => onPublication(ctx.data);
|
|
86
|
+
sub.on('publication', pubHandler);
|
|
87
|
+
|
|
88
|
+
let subHandler: () => void = null;
|
|
89
|
+
if (onSubscribed) {
|
|
90
|
+
// fires on every (re)subscribe, including after reconnects, so
|
|
91
|
+
// subscribers can catch up on anything missed while offline
|
|
92
|
+
subHandler = () => onSubscribed();
|
|
93
|
+
sub.on('subscribed', subHandler);
|
|
94
|
+
|
|
95
|
+
// late joiners on an already-live channel won't see a subscribed
|
|
96
|
+
// event, so give them their initial one
|
|
97
|
+
if (sub.state === SubscriptionState.Subscribed) {
|
|
98
|
+
window.setTimeout(() => subHandler && subHandler(), 0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let active = true;
|
|
103
|
+
return {
|
|
104
|
+
unsubscribe: () => {
|
|
105
|
+
if (!active) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
active = false;
|
|
109
|
+
|
|
110
|
+
sub.off('publication', pubHandler);
|
|
111
|
+
if (subHandler) {
|
|
112
|
+
sub.off('subscribed', subHandler);
|
|
113
|
+
subHandler = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
entry.count--;
|
|
117
|
+
if (entry.count === 0) {
|
|
118
|
+
this.channels.delete(channel);
|
|
119
|
+
sub.unsubscribe();
|
|
120
|
+
this.socket.removeSubscription(sub);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// the page-scoped manager, shared with vanilla js as window.sockets and
|
|
128
|
+
// reused if another copy of this module already created it
|
|
129
|
+
const getManager = (): SocketManager => {
|
|
130
|
+
const w = window as any;
|
|
131
|
+
if (!w.sockets) {
|
|
132
|
+
w.sockets = new SocketManager();
|
|
133
|
+
}
|
|
134
|
+
return w.sockets;
|
|
135
|
+
};
|
|
136
|
+
getManager();
|
|
137
|
+
|
|
138
|
+
// when set, components subscribe through this instead of the page manager
|
|
139
|
+
let provider: SocketProvider = null;
|
|
140
|
+
|
|
141
|
+
export const subscribeToSocket = (
|
|
142
|
+
channel: string,
|
|
143
|
+
onPublication: PublicationHandler,
|
|
144
|
+
onSubscribed?: () => void
|
|
145
|
+
): SocketSubscription => {
|
|
146
|
+
return (provider || getManager()).subscribe(
|
|
147
|
+
channel,
|
|
148
|
+
onPublication,
|
|
149
|
+
onSubscribed
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// for tests to swap in a mock provider, returns the previous provider
|
|
154
|
+
export const setSocketProvider = (newProvider: SocketProvider) => {
|
|
155
|
+
const previous = provider;
|
|
156
|
+
provider = newProvider;
|
|
157
|
+
return previous;
|
|
158
|
+
};
|
package/src/locales/es.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
'scf1453991c986b25': `Tab para completar, enter para seleccionar`,
|
|
13
13
|
's73b4d70c02f4b4e0': `No options`,
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
|
+
's81f17cfc89a04338': `Interrupt flow`,
|
|
15
16
|
's8f02e3a18ffc083a': `Are not currently in a flow`,
|
|
16
17
|
's638236250662c6b3': `Have sent a message in the last`,
|
|
17
18
|
's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
|
|
19
|
+
'sea4f08110bb8f15d': `Remove`,
|
|
18
20
|
};
|
|
19
21
|
|
package/src/locales/fr.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
's73b4d70c02f4b4e0': `No options`,
|
|
13
13
|
'scf1453991c986b25': `Tab to complete, enter to select`,
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
|
+
's81f17cfc89a04338': `Interrupt flow`,
|
|
15
16
|
's8f02e3a18ffc083a': `Are not currently in a flow`,
|
|
16
17
|
's638236250662c6b3': `Have sent a message in the last`,
|
|
17
18
|
's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
|
|
19
|
+
'sea4f08110bb8f15d': `Remove`,
|
|
18
20
|
};
|
|
19
21
|
|
package/src/locales/pt.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
's73b4d70c02f4b4e0': `No options`,
|
|
13
13
|
'scf1453991c986b25': `Tab to complete, enter to select`,
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
|
+
's81f17cfc89a04338': `Interrupt flow`,
|
|
15
16
|
's8f02e3a18ffc083a': `Are not currently in a flow`,
|
|
16
17
|
's638236250662c6b3': `Have sent a message in the last`,
|
|
17
18
|
's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
|
|
19
|
+
'sea4f08110bb8f15d': `Remove`,
|
|
18
20
|
};
|
|
19
21
|
|
package/src/store/Store.ts
CHANGED
|
@@ -158,6 +158,23 @@ export class Store extends RapidElement {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
private cache: any;
|
|
161
|
+
|
|
162
|
+
// user avatar urls by user uuid, seeded from server-hydrated user
|
|
163
|
+
// references so ones that arrive without an avatar (e.g. events published
|
|
164
|
+
// over sockets) can be filled in from users we've already seen. LRU-bounded
|
|
165
|
+
// so long-lived tabs don't accumulate every user ever seen.
|
|
166
|
+
private userAvatars = lru<string>(500);
|
|
167
|
+
|
|
168
|
+
public setUserAvatar(uuid: string, avatar: string) {
|
|
169
|
+
if (uuid && avatar) {
|
|
170
|
+
this.userAvatars.set(uuid, avatar);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public getUserAvatar(uuid: string): string {
|
|
175
|
+
return this.userAvatars.get(uuid);
|
|
176
|
+
}
|
|
177
|
+
|
|
161
178
|
public getLocale() {
|
|
162
179
|
return this.locale[0];
|
|
163
180
|
}
|
|
@@ -561,6 +578,7 @@ export class Store extends RapidElement {
|
|
|
561
578
|
const results = response.json.results;
|
|
562
579
|
if (results && results.length === 1) {
|
|
563
580
|
const user = results[0];
|
|
581
|
+
this.setUserAvatar(user.uuid, user.avatar);
|
|
564
582
|
|
|
565
583
|
items.forEach((item) => {
|
|
566
584
|
// replace each key with a matching user
|
package/xliff/es.xlf
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
<trans-unit id="sbc913d7dc0f33877">
|
|
22
22
|
<source>to add</source>
|
|
23
23
|
</trans-unit>
|
|
24
|
+
<trans-unit id="sea4f08110bb8f15d">
|
|
25
|
+
<source>Remove</source>
|
|
26
|
+
</trans-unit>
|
|
27
|
+
<trans-unit id="s81f17cfc89a04338">
|
|
28
|
+
<source>Interrupt flow</source>
|
|
29
|
+
</trans-unit>
|
|
24
30
|
</body>
|
|
25
31
|
</file>
|
|
26
32
|
</xliff>
|
package/xliff/fr.xlf
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
<trans-unit id="sbc913d7dc0f33877">
|
|
21
21
|
<source>to add</source>
|
|
22
22
|
</trans-unit>
|
|
23
|
+
<trans-unit id="sea4f08110bb8f15d">
|
|
24
|
+
<source>Remove</source>
|
|
25
|
+
</trans-unit>
|
|
26
|
+
<trans-unit id="s81f17cfc89a04338">
|
|
27
|
+
<source>Interrupt flow</source>
|
|
28
|
+
</trans-unit>
|
|
23
29
|
</body>
|
|
24
30
|
</file>
|
|
25
31
|
</xliff>
|
package/xliff/pt.xlf
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
<trans-unit id="sbc913d7dc0f33877">
|
|
21
21
|
<source>to add</source>
|
|
22
22
|
</trans-unit>
|
|
23
|
+
<trans-unit id="sea4f08110bb8f15d">
|
|
24
|
+
<source>Remove</source>
|
|
25
|
+
</trans-unit>
|
|
26
|
+
<trans-unit id="s81f17cfc89a04338">
|
|
27
|
+
<source>Interrupt flow</source>
|
|
28
|
+
</trans-unit>
|
|
23
29
|
</body>
|
|
24
30
|
</file>
|
|
25
31
|
</xliff>
|