@nyaruka/temba-components 0.160.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.160.1",
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
  },
@@ -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;
@@ -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
  }
@@ -200,7 +200,10 @@ export const split_by_webhook: NodeConfig = {
200
200
  callWebhookAction?.headers ||
201
201
  getDefaultHeadersRecord(callWebhookAction?.method || 'GET'),
202
202
  body: callWebhookAction?.body || '',
203
- result_name: node.router?.result_name || ''
203
+ // Legacy webhooks store result_name on the action; modern ones store it
204
+ // on the router. Prefer the action so legacy nodes round-trip correctly.
205
+ result_name:
206
+ callWebhookAction?.result_name || node.router?.result_name || ''
204
207
  };
205
208
  },
206
209
  fromFormData: (formData: FormData, originalNode: Node): Node => {
@@ -213,9 +216,17 @@ export const split_by_webhook: NodeConfig = {
213
216
  // Find existing call_webhook action to preserve its UUID
214
217
  const existingCallWebhookAction = originalNode.actions?.find(
215
218
  (action) => action.type === 'call_webhook'
216
- );
219
+ ) as CallWebhook | undefined;
217
220
  const callWebhookUuid = existingCallWebhookAction?.uuid || generateUUID();
218
221
 
222
+ // Legacy webhooks keep the result_name on the action rather than the
223
+ // router. Detect them by the presence of result_name on the existing
224
+ // action so they continue to round-trip in their original format instead
225
+ // of being silently migrated (which would change how results are
226
+ // referenced downstream of the flow).
227
+ const isLegacyResultName = !!existingCallWebhookAction?.result_name;
228
+ const trimmedResultName = formData.result_name?.trim() || '';
229
+
219
230
  // Create call_webhook action
220
231
  const callWebhookAction: CallWebhook = {
221
232
  type: 'call_webhook',
@@ -226,6 +237,11 @@ export const split_by_webhook: NodeConfig = {
226
237
  body: formData.body || ''
227
238
  };
228
239
 
240
+ // Preserve the legacy placement: result_name stays on the action.
241
+ if (isLegacyResultName && trimmedResultName !== '') {
242
+ callWebhookAction.result_name = trimmedResultName;
243
+ }
244
+
229
245
  // Create categories and exits for Success and Failure
230
246
  const existingCategories = originalNode.router?.categories || [];
231
247
  const existingExits = originalNode.exits || [];
@@ -247,9 +263,11 @@ export const split_by_webhook: NodeConfig = {
247
263
  ...router
248
264
  };
249
265
 
250
- // Only set result_name if provided
251
- if (formData.result_name && formData.result_name.trim() !== '') {
252
- finalRouter.result_name = formData.result_name.trim();
266
+ // Modern webhooks store result_name on the router. For legacy webhooks the
267
+ // result_name lives on the action (set above), so we leave it off the
268
+ // router to avoid storing it in both places.
269
+ if (!isLegacyResultName && trimmedResultName !== '') {
270
+ finalRouter.result_name = trimmedResultName;
253
271
  }
254
272
 
255
273
  // Return the complete node
@@ -1,7 +1,7 @@
1
1
  import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
- import { Flow, ObjectReference } from '../interfaces';
4
+ import { CustomEventType, Flow, ObjectReference } from '../interfaces';
5
5
 
6
6
  /**
7
7
  * Flow CRUDL list — drop-in replacement for the rapidpro
@@ -89,27 +89,28 @@ export class FlowList extends ContentList<Flow> {
89
89
  key: 'name',
90
90
  label: 'Name',
91
91
  sortable: true,
92
- width: '280px',
92
+ minWidth: '160px',
93
+ maxWidth: '280px',
93
94
  pinned: true
94
95
  },
95
96
  {
96
97
  key: 'runs',
97
98
  label: 'Runs',
98
99
  sortable: true,
99
- width: '90px',
100
+ minWidth: '64px',
100
101
  align: 'right'
101
102
  },
102
103
  {
103
104
  key: 'ongoing',
104
105
  label: 'Ongoing',
105
106
  sortable: true,
106
- width: '90px',
107
+ minWidth: '64px',
107
108
  align: 'right'
108
109
  },
109
110
  {
110
111
  key: 'completion',
111
112
  label: 'Completion',
112
- width: '130px',
113
+ minWidth: '110px',
113
114
  align: 'right'
114
115
  },
115
116
  { key: 'activity', label: 'Activity', width: '120px', align: 'right' }
@@ -146,6 +147,25 @@ export class FlowList extends ContentList<Flow> {
146
147
  return item?.uuid ? `/flow/editor/${item.uuid}/` : null;
147
148
  }
148
149
 
150
+ /** Clicking a label chip opens that label's filtered flow list rather
151
+ * than falling through to the row click (which opens the flow editor). */
152
+ private handleLabelClick(label: ObjectReference, event: MouseEvent): void {
153
+ // Stop the click from bubbling to the row's navigation handler.
154
+ event.stopPropagation();
155
+ if (!label?.uuid) return;
156
+ const href = `/flow/filter/${label.uuid}/`;
157
+ // Guard the JSON-driven href against open-redirect, same as the
158
+ // row-click path in ContentList.handleRowClick.
159
+ if (!this.isSafeHref(href)) return;
160
+ // Meta/ctrl-click opens a new tab, matching ordinary links and the
161
+ // row-click behavior.
162
+ if (event.metaKey || event.ctrlKey) {
163
+ window.open(href, '_blank');
164
+ return;
165
+ }
166
+ this.fireCustomEvent(CustomEventType.Redirected, { url: href });
167
+ }
168
+
149
169
  protected renderCell(
150
170
  item: Flow,
151
171
  column: ContentListColumn
@@ -166,7 +186,11 @@ export class FlowList extends ContentList<Flow> {
166
186
  ? html`<span class="flow-labels">
167
187
  ${labels.map(
168
188
  (l: ObjectReference) =>
169
- html`<temba-label type="label" icon=${Icon.label}
189
+ html`<temba-label
190
+ type="label"
191
+ icon=${Icon.label}
192
+ clickable
193
+ @click=${(e: MouseEvent) => this.handleLabelClick(l, e)}
170
194
  >${l.name}</temba-label
171
195
  >`
172
196
  )}
@@ -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 polling new messages
531
- refreshId = null;
532
- polling = false;
533
- pollingInterval = 2000; // start at 2 seconds
534
- lastFetchTime: number = null;
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
- if (this.refreshId) {
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('data') ||
580
- changedProperties.has('currentContact')
570
+ changedProperties.has('currentContact') ||
571
+ changedProperties.has('currentTicket')
581
572
  ) {
582
- // unschedule any previous refreshes
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
- setTimeout(() => this.checkForNewMessages(), 500);
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.refreshId = null;
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
- private checkForNewMessages() {
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.polling || this.searchMode) {
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.polling = true;
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
- endpoint,
1069
- this.currentTicket?.uuid,
1070
- null,
1071
- this.afterUUID
1072
- ).then((page: ContactHistoryPage) => {
1073
- const messages = this.createMessages(page);
1074
- messages.reverse();
1075
- if (fetchContact === this.currentContact.uuid) {
1076
- const hasNewEvents = messages.length > 0;
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
  }