@nyaruka/temba-components 0.168.1 → 0.170.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.
@@ -0,0 +1,415 @@
1
+ import { Contact } from '../interfaces';
2
+ import { Events } from '../events/eventRenderers';
3
+ import { getStore } from '../store/Store';
4
+ import { RealtimeSubscription, subscribeToContactHistory } from './Realtime';
5
+
6
+ /**
7
+ * Central interest registry for live contact state. Components declare what
8
+ * they care about - specific event types or '*' for everything - and this
9
+ * module owns the rest: it holds the single socket subscription per contact
10
+ * (the firehose on "history:<contact-uuid>"), fetches the contact so watchers
11
+ * get an initial value without something having to change, and fans each
12
+ * event out to just the watchers whose interest matches.
13
+ *
14
+ * const watch = watchContact(uuid, [Events.CONTACT_NAME_CHANGED], (event, contact) => {
15
+ * ...
16
+ * });
17
+ * watch.unsubscribe();
18
+ *
19
+ * Every delivery hands the watcher the current contact alongside the event
20
+ * that triggered it, so watchers can read state without knowing where the
21
+ * data originally came from. The contact is kept current centrally: scalar
22
+ * events are applied to it directly, while events whose full effect isn't in
23
+ * the event payload (see refetchTypes) trigger a refetch. Initial values and
24
+ * refetches arrive through the same handler as live changes, as a delivery
25
+ * with no event, so watchers have a single code path. The fetch happens on
26
+ * every (re)subscribe, including after reconnects, so watchers also catch up
27
+ * on anything missed while offline. When the last watcher for a contact
28
+ * unsubscribes the socket subscription and cached contact are dropped.
29
+ *
30
+ * Wildcard watchers are event-stream consumers (e.g. chat renders history) -
31
+ * they get every live event but no eventless deliveries.
32
+ */
33
+
34
+ // same shape components previously fetched themselves - urns are expanded and
35
+ // priority ordered so watchers can pick the messaging destination
36
+ const CONTACT_ENDPOINT =
37
+ '/api/v2/contacts.json?expand_urns=true&urn_order=priority&uuid=';
38
+
39
+ // how long we wait for an event burst (e.g. one flow sprint updating several
40
+ // fields) to settle before refetching
41
+ const REFETCH_DEBOUNCE = 100;
42
+
43
+ // a fetch can fail on an otherwise healthy socket, which would leave watchers
44
+ // with no contact at all until something else triggers a fetch - retry a few
45
+ // times, backing off between attempts
46
+ const FETCH_RETRIES = 3;
47
+ const FETCH_RETRY_DELAY = 1000;
48
+
49
+ // the contact-state events - everything that changes what a contact *is*, as
50
+ // opposed to recording something that happened to them. The default interest
51
+ // for components that render contact data
52
+ export const CONTACT_STATE_TYPES = [
53
+ Events.CONTACT_NAME_CHANGED,
54
+ Events.CONTACT_URNS_CHANGED,
55
+ Events.CONTACT_FIELD_CHANGED,
56
+ Events.CONTACT_GROUPS_CHANGED,
57
+ Events.CONTACT_LANGUAGE_CHANGED,
58
+ Events.CONTACT_STATUS_CHANGED,
59
+ Events.CONTACT_FLOW_CHANGED,
60
+ Events.CONTACT_LAST_SEEN_CHANGED
61
+ ];
62
+
63
+ export type ContactEventHandler = (event: any, contact: Contact) => void;
64
+
65
+ interface Watcher {
66
+ types: string[] | '*';
67
+ onEvent: ContactEventHandler;
68
+ }
69
+
70
+ interface WatchedContact {
71
+ watchers: Watcher[];
72
+ sub: RealtimeSubscription;
73
+ contact: Contact;
74
+ fetchSeq: number;
75
+ // fetches in flight - their responses predate anything arriving now, so
76
+ // events landing while one is outstanding need a refetch to survive
77
+ fetching: number;
78
+ refetchTimer: number;
79
+ }
80
+
81
+ const watched = new Map<string, WatchedContact>();
82
+
83
+ /**
84
+ * Serializes an engine field value from an event the way the read API
85
+ * would - both read the same engine value dict, keyed by the field's type
86
+ * (the API calls number fields "numeric"). A value that didn't parse as the
87
+ * field's type serializes as null, same as the API. Returns undefined when
88
+ * the field definition isn't known, since without it there's no way to tell
89
+ * which engine value the API would have read.
90
+ */
91
+ const serializeFieldValue = (key: string, value: any): string | undefined => {
92
+ if (!value) {
93
+ return null;
94
+ }
95
+ const fieldType = getStore()?.getContactField(key)?.value_type;
96
+ if (!fieldType) {
97
+ return undefined;
98
+ }
99
+ const engineType = fieldType === 'numeric' ? 'number' : fieldType;
100
+ const engineValue = value[engineType];
101
+ return engineValue != null ? String(engineValue) : null;
102
+ };
103
+
104
+ // how live events patch the cached contact so every delivery carries current
105
+ // state. Returning false means the event couldn't be applied and the contact
106
+ // needs a refetch to be current
107
+ const appliers: {
108
+ [type: string]: (contact: Contact, event: any) => boolean | void;
109
+ } = {
110
+ [Events.CONTACT_NAME_CHANGED]: (contact, event) => {
111
+ contact.name = event.name;
112
+ },
113
+ [Events.CONTACT_LANGUAGE_CHANGED]: (contact, event) => {
114
+ contact.language = event.language;
115
+ },
116
+ [Events.CONTACT_STATUS_CHANGED]: (contact, event) => {
117
+ contact.status = event.status;
118
+ },
119
+ [Events.CONTACT_FLOW_CHANGED]: (contact, event) => {
120
+ contact.flow = event.flow || null;
121
+ },
122
+ [Events.CONTACT_LAST_SEEN_CHANGED]: (contact, event) => {
123
+ // last seen only ever moves forward - ignore out of order deliveries
124
+ if (
125
+ !contact.last_seen_on ||
126
+ new Date(event.last_seen_on) > new Date(contact.last_seen_on)
127
+ ) {
128
+ contact.last_seen_on = event.last_seen_on;
129
+ }
130
+ },
131
+ [Events.CONTACT_FIELD_CHANGED]: (contact, event) => {
132
+ const key = event.field?.key;
133
+ if (!key) {
134
+ return;
135
+ }
136
+ const value = serializeFieldValue(key, event.value);
137
+ if (value === undefined) {
138
+ // no field definition to serialize against - don't guess at a value
139
+ return false;
140
+ }
141
+ // replace rather than mutate so earlier deliveries keep their values
142
+ contact.fields = { ...contact.fields, [key]: value };
143
+ },
144
+ [Events.CONTACT_GROUPS_CHANGED]: (contact, event) => {
145
+ const added = event.groups_added || [];
146
+ const removed = new Set(
147
+ (event.groups_removed || []).map((group: any) => group.uuid)
148
+ );
149
+ contact.groups = [
150
+ ...(contact.groups || []).filter(
151
+ (group) =>
152
+ !removed.has(group.uuid) &&
153
+ !added.some((a: any) => a.uuid === group.uuid)
154
+ ),
155
+ // group references can arrive without a name, but consumers sort on it
156
+ ...added.map((group: any) => ({
157
+ uuid: group.uuid,
158
+ name: group.name || ''
159
+ }))
160
+ ];
161
+ }
162
+ };
163
+
164
+ // events whose new state can't be patched into the cached contact from the
165
+ // event alone - urns arrive as raw strings while the contact carries them
166
+ // expanded with their channels, which picking a messaging destination needs.
167
+ // Their arrival triggers a (debounced) refetch, which re-primes every
168
+ // watcher with current values.
169
+ const refetchTypes = new Set<string>([Events.CONTACT_URNS_CHANGED]);
170
+
171
+ const matches = (watcher: Watcher, type: string): boolean => {
172
+ return watcher.types === '*' || watcher.types.includes(type);
173
+ };
174
+
175
+ // watchers are page components we don't control - one of them throwing can't
176
+ // be allowed to cost the others their delivery
177
+ const deliver = (watcher: Watcher, event: any, contact: Contact) => {
178
+ try {
179
+ watcher.onEvent(event, contact);
180
+ } catch (error) {
181
+ console.error('contact watcher failed', error);
182
+ }
183
+ };
184
+
185
+ /**
186
+ * Hands every non-wildcard watcher the current contact as an eventless
187
+ * delivery.
188
+ */
189
+ const primeAll = (entry: WatchedContact) => {
190
+ for (const watcher of [...entry.watchers]) {
191
+ if (watcher.types !== '*') {
192
+ deliver(watcher, null, entry.contact);
193
+ }
194
+ }
195
+ };
196
+
197
+ const clearRefetch = (entry: WatchedContact) => {
198
+ if (entry.refetchTimer) {
199
+ window.clearTimeout(entry.refetchTimer);
200
+ entry.refetchTimer = null;
201
+ }
202
+ };
203
+
204
+ // a fetch that couldn't happen leaves watchers with no value at all, so try
205
+ // again with a backoff - anything that supersedes us in the meantime (a newer
206
+ // fetch, a local write, the last watcher leaving) cancels it
207
+ const retryFetch = (
208
+ uuid: string,
209
+ entry: WatchedContact,
210
+ attempt: number,
211
+ seq: number
212
+ ) => {
213
+ if (attempt >= FETCH_RETRIES) {
214
+ return;
215
+ }
216
+
217
+ window.setTimeout(
218
+ () => {
219
+ if (watched.get(uuid) === entry && entry.fetchSeq === seq) {
220
+ fetchContact(uuid, entry, attempt + 1);
221
+ }
222
+ },
223
+ FETCH_RETRY_DELAY * Math.pow(2, attempt)
224
+ );
225
+ };
226
+
227
+ const fetchContact = (uuid: string, entry: WatchedContact, attempt = 0) => {
228
+ const store = getStore();
229
+ if (!store) {
230
+ // the store element can be added to the page after the components that
231
+ // watch through it - come back for it instead of leaving watchers empty
232
+ // for good. Claim a seq like a real fetch does so a later call supersedes
233
+ // this chain instead of running alongside it
234
+ retryFetch(uuid, entry, attempt, ++entry.fetchSeq);
235
+ return;
236
+ }
237
+
238
+ // a pending debounce would only land behind us with the same data
239
+ clearRefetch(entry);
240
+
241
+ const seq = ++entry.fetchSeq;
242
+ entry.fetching++;
243
+ store
244
+ // skip the store cache in both directions - we always want current data
245
+ // and the cache holds this url in a different shape for components that
246
+ // still fetch it themselves
247
+ .getUrl(`${CONTACT_ENDPOINT}${uuid}`, { force: true, skipCache: true })
248
+ // two-argument form so a throw out of the success handler isn't taken
249
+ // for a failed fetch and retried on top of itself
250
+ .then(
251
+ (response) => {
252
+ entry.fetching--;
253
+
254
+ // ignore responses that arrive after everyone left or a newer fetch
255
+ if (watched.get(uuid) !== entry || entry.fetchSeq !== seq) {
256
+ return;
257
+ }
258
+
259
+ const contact = response.json?.results?.[0] || null;
260
+ if (!contact) {
261
+ return;
262
+ }
263
+
264
+ entry.contact = contact;
265
+ primeAll(entry);
266
+ },
267
+ () => {
268
+ entry.fetching--;
269
+
270
+ if (watched.get(uuid) !== entry || entry.fetchSeq !== seq) {
271
+ return;
272
+ }
273
+
274
+ retryFetch(uuid, entry, attempt, seq);
275
+ }
276
+ );
277
+ };
278
+
279
+ const scheduleRefetch = (uuid: string, entry: WatchedContact) => {
280
+ clearRefetch(entry);
281
+ entry.refetchTimer = window.setTimeout(() => {
282
+ entry.refetchTimer = null;
283
+ if (watched.get(uuid) === entry) {
284
+ fetchContact(uuid, entry);
285
+ }
286
+ }, REFETCH_DEBOUNCE);
287
+ };
288
+
289
+ const handleEvent = (uuid: string, entry: WatchedContact, event: any) => {
290
+ const apply = appliers[event.type];
291
+ const applied = entry.contact && apply ? apply(entry.contact, event) : null;
292
+
293
+ // a state event we couldn't fully apply leaves the contact stale - there
294
+ // was no snapshot to patch, a fetch that predates the event is still in
295
+ // flight, or the applier couldn't resolve what it needed. Scheduling ahead
296
+ // of the fan-out keeps a throwing watcher from costing us the refetch, and
297
+ // the debounce collapses a burst into a single fetch
298
+ const stale =
299
+ !!apply && (!entry.contact || entry.fetching > 0 || applied === false);
300
+ if (stale || refetchTypes.has(event.type)) {
301
+ scheduleRefetch(uuid, entry);
302
+ }
303
+
304
+ for (const watcher of [...entry.watchers]) {
305
+ if (matches(watcher, event.type)) {
306
+ deliver(watcher, event, entry.contact);
307
+ }
308
+ }
309
+ };
310
+
311
+ export const watchContact = (
312
+ uuid: string,
313
+ types: string[] | '*',
314
+ onEvent: ContactEventHandler
315
+ ): RealtimeSubscription => {
316
+ let entry = watched.get(uuid);
317
+ if (!entry) {
318
+ const newEntry: WatchedContact = {
319
+ watchers: [],
320
+ sub: null,
321
+ contact: null,
322
+ fetchSeq: 0,
323
+ fetching: 0,
324
+ refetchTimer: null
325
+ };
326
+ watched.set(uuid, newEntry);
327
+ newEntry.sub = subscribeToContactHistory(
328
+ uuid,
329
+ null,
330
+ (event) => handleEvent(uuid, newEntry, event),
331
+ // fires on every (re)subscribe incl. after reconnects - (re)fetch so
332
+ // watchers see anything that changed while we weren't listening
333
+ () => fetchContact(uuid, newEntry)
334
+ );
335
+ entry = newEntry;
336
+ }
337
+
338
+ const watcher: Watcher = { types, onEvent };
339
+ entry.watchers.push(watcher);
340
+
341
+ // late joiners on an already-fetched contact get their initial delivery
342
+ // without waiting for another fetch - async so it mirrors the fetch path
343
+ if (entry.contact && types !== '*') {
344
+ const contact = entry.contact;
345
+ Promise.resolve().then(() => {
346
+ if (entry.watchers.includes(watcher)) {
347
+ deliver(watcher, null, contact);
348
+ }
349
+ });
350
+ }
351
+
352
+ return {
353
+ unsubscribe: () => {
354
+ const index = entry.watchers.indexOf(watcher);
355
+ if (index < 0) {
356
+ return;
357
+ }
358
+ entry.watchers.splice(index, 1);
359
+ if (entry.watchers.length === 0) {
360
+ dropEntry(uuid, entry);
361
+ }
362
+ }
363
+ };
364
+ };
365
+
366
+ /**
367
+ * Pushes a fresh copy of a contact into the registry, priming its watchers.
368
+ * Components that write contact changes call this with the server's response
369
+ * so every watcher on the page reflects an edit immediately, without waiting
370
+ * for the change to echo back over the socket. A no-op for unwatched
371
+ * contacts.
372
+ */
373
+ export const updateContact = (uuid: string, contact: Contact) => {
374
+ const entry = watched.get(uuid);
375
+ if (entry) {
376
+ // discard any in-flight fetch and any pending refetch - both started
377
+ // before this write and could land after it with pre-write data
378
+ entry.fetchSeq++;
379
+ clearRefetch(entry);
380
+ // the caller keeps using the object they handed us (it's their data and
381
+ // the store's cache entry) while appliers patch ours in place - copy the
382
+ // parts they touch so a live event can't reach back into it
383
+ entry.contact = {
384
+ ...contact,
385
+ groups: (contact.groups || []).map((group) => ({ ...group })),
386
+ fields: { ...contact.fields }
387
+ };
388
+ primeAll(entry);
389
+ }
390
+ };
391
+
392
+ /**
393
+ * Forces a refetch of a watched contact, re-priming its watchers. A no-op
394
+ * for unwatched contacts.
395
+ */
396
+ export const refreshContact = (uuid: string) => {
397
+ const entry = watched.get(uuid);
398
+ if (entry) {
399
+ fetchContact(uuid, entry);
400
+ }
401
+ };
402
+
403
+ const dropEntry = (uuid: string, entry: WatchedContact) => {
404
+ watched.delete(uuid);
405
+ clearRefetch(entry);
406
+ entry.sub.unsubscribe();
407
+ };
408
+
409
+ // for tests - real pages just unwatch
410
+ export const resetContactWatches = () => {
411
+ for (const [uuid, entry] of [...watched.entries()]) {
412
+ entry.watchers.length = 0;
413
+ dropEntry(uuid, entry);
414
+ }
415
+ };
package/src/locales/es.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
package/src/locales/fr.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
package/src/locales/pt.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
@@ -630,6 +630,7 @@ export class Simulator extends RapidElement {
630
630
  }
631
631
  .message-input input {
632
632
  flex: 1;
633
+ min-width: 0;
633
634
  border: 1px solid #c6c6c857;
634
635
  border-radius: 20px;
635
636
  padding: 8px 15px;
package/temba-modules.ts CHANGED
@@ -30,13 +30,11 @@ import { ContactUrn } from './src/display/ContactUrn';
30
30
  import { ContactFields } from './src/live/ContactFields';
31
31
  import { ContactFieldEditor } from './src/live/ContactFieldEditor';
32
32
 
33
- import { ContactBadges } from './src/live/ContactBadges';
34
33
  import { ContactTimeline } from './src/live/ContactTimeline';
35
34
  import { CampaignEvents } from './src/live/CampaignEvents';
36
35
  import { TembaSlider } from './src/form/TembaSlider';
37
36
  import { RunList } from './src/list/RunList';
38
37
  import { FlowStoreElement } from './src/store/FlowStoreElement';
39
- import { ContactNameFetch } from './src/live/ContactNameFetch';
40
38
  import { DatePicker } from './src/form/DatePicker';
41
39
  import { FieldManager } from './src/live/FieldManager';
42
40
  import { SortableList } from './src/list/SortableList';
@@ -125,7 +123,6 @@ addCustomElement('temba-button', Button);
125
123
  addCustomElement('temba-omnibox', Omnibox);
126
124
  addCustomElement('temba-tip', Tip);
127
125
  addCustomElement('temba-contact-name', ContactName);
128
- addCustomElement('temba-contact-name-fetch', ContactNameFetch);
129
126
  addCustomElement('temba-contact-field', ContactFieldEditor);
130
127
  addCustomElement('temba-contact-fields', ContactFields);
131
128
  addCustomElement('temba-field-manager', FieldManager);
@@ -162,7 +159,6 @@ addCustomElement('temba-icon', VectorIcon);
162
159
  addCustomElement('temba-dropdown', Dropdown);
163
160
  addCustomElement('temba-tabs', TabPane);
164
161
  addCustomElement('temba-tab', Tab);
165
- addCustomElement('temba-contact-badges', ContactBadges);
166
162
  addCustomElement('temba-contact-timeline', ContactTimeline);
167
163
  addCustomElement('temba-campaign-events', CampaignEvents);
168
164
  addCustomElement('temba-slider', TembaSlider);
package/xliff/es.xlf CHANGED
@@ -42,6 +42,9 @@
42
42
  <trans-unit id="s7722a91d3a512442">
43
43
  <source>Last seen <x id="0" equiv-text="${lastSeen.duration}"/></source>
44
44
  </trans-unit>
45
+ <trans-unit id="s6dbbe2646b239ca5">
46
+ <source>Closed</source>
47
+ </trans-unit>
45
48
  </body>
46
49
  </file>
47
50
  </xliff>
package/xliff/fr.xlf CHANGED
@@ -41,6 +41,9 @@
41
41
  <trans-unit id="s7722a91d3a512442">
42
42
  <source>Last seen <x id="0" equiv-text="${lastSeen.duration}"/></source>
43
43
  </trans-unit>
44
+ <trans-unit id="s6dbbe2646b239ca5">
45
+ <source>Closed</source>
46
+ </trans-unit>
44
47
  </body>
45
48
  </file>
46
49
  </xliff>
package/xliff/pt.xlf CHANGED
@@ -41,6 +41,9 @@
41
41
  <trans-unit id="s7722a91d3a512442">
42
42
  <source>Last seen <x id="0" equiv-text="${lastSeen.duration}"/></source>
43
43
  </trans-unit>
44
+ <trans-unit id="s6dbbe2646b239ca5">
45
+ <source>Closed</source>
46
+ </trans-unit>
44
47
  </body>
45
48
  </file>
46
49
  </xliff>