@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.161.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
  },
@@ -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
  }
@@ -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
  }
@@ -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
 
@@ -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>