@nyaruka/temba-components 0.170.1 → 0.172.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.170.1",
3
+ "version": "0.172.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",
@@ -40,6 +40,34 @@ export interface EventHandler {
40
40
  isWindow?: boolean;
41
41
  }
42
42
 
43
+ /**
44
+ * Inline handlers compiled from an element's -<event-type> attribute, keyed
45
+ * by their source. Page authors write a finite set of these, so compiling
46
+ * each one once keeps dispatch off the Function constructor entirely.
47
+ */
48
+ const inlineHandlers = new Map<string, (event: Event) => any>();
49
+
50
+ const compileInlineHandler = (source: string): ((event: Event) => any) => {
51
+ let compiled = inlineHandlers.get(source);
52
+ if (!compiled) {
53
+ compiled = new Function(
54
+ 'event',
55
+ `
56
+ with(document) {
57
+ with(this) {
58
+ let handler = ${source};
59
+ if(typeof handler === 'function') {
60
+ handler(event);
61
+ }
62
+ }
63
+ }
64
+ `
65
+ ) as (event: Event) => any;
66
+ inlineHandlers.set(source, compiled);
67
+ }
68
+ return compiled;
69
+ };
70
+
43
71
  export class RapidElement extends LitElement {
44
72
  DEBUG = false;
45
73
  DEBUG_UPDATES = false;
@@ -49,34 +77,50 @@ export class RapidElement extends LitElement {
49
77
  service: string;
50
78
 
51
79
  private eles: { [selector: string]: HTMLDivElement } = {};
80
+
81
+ // teardowns for the listeners we installed, so disconnecting removes the
82
+ // exact functions we added
83
+ private listenerTeardowns: (() => void)[] = [];
84
+
52
85
  public getEventHandlers(): EventHandler[] {
53
86
  return [];
54
87
  }
55
88
 
89
+ /**
90
+ * Adds a listener this element owns the teardown for - it is bound to this
91
+ * element and removed automatically when we disconnect. Use this instead of
92
+ * addEventListener for anything on document or window, where a listener
93
+ * that outlives its element keeps the whole element alive.
94
+ */
95
+ public listenTo(
96
+ target: EventTarget,
97
+ event: string,
98
+ method: EventListener,
99
+ options?: AddEventListenerOptions
100
+ ): void {
101
+ const bound = method.bind(this);
102
+ target.addEventListener(event, bound, options);
103
+ this.listenerTeardowns.push(() =>
104
+ target.removeEventListener(event, bound, options)
105
+ );
106
+ }
107
+
56
108
  connectedCallback() {
57
109
  super.connectedCallback();
58
110
 
59
111
  for (const handler of this.getEventHandlers()) {
60
- if (handler.isDocument) {
61
- document.addEventListener(handler.event, handler.method.bind(this));
62
- } else if (handler.isWindow) {
63
- window.addEventListener(handler.event, handler.method.bind(this));
64
- } else {
65
- this.addEventListener(handler.event, handler.method.bind(this));
66
- }
112
+ const target = handler.isDocument
113
+ ? document
114
+ : handler.isWindow
115
+ ? window
116
+ : this;
117
+ this.listenTo(target, handler.event, handler.method);
67
118
  }
68
119
  }
69
120
 
70
121
  disconnectedCallback() {
71
- for (const handler of this.getEventHandlers()) {
72
- if (handler.isDocument) {
73
- document.removeEventListener(handler.event, handler.method);
74
- } else if (handler.isWindow) {
75
- window.removeEventListener(handler.event, handler.method);
76
- } else {
77
- this.removeEventListener(handler.event, handler.method);
78
- }
79
- }
122
+ this.listenerTeardowns.forEach((teardown) => teardown());
123
+ this.listenerTeardowns = [];
80
124
  super.disconnectedCallback();
81
125
  }
82
126
 
@@ -121,9 +165,7 @@ export class RapidElement extends LitElement {
121
165
  }
122
166
 
123
167
  public fireCustomEvent(type: CustomEventType, detail: any = {}): any {
124
- if (this['DEBUG_EVENTS']) {
125
- showEvent(this, type, detail);
126
- }
168
+ showEvent(this, type, detail);
127
169
 
128
170
  const event = new CustomEvent(type, {
129
171
  detail,
@@ -135,30 +177,30 @@ export class RapidElement extends LitElement {
135
177
  }
136
178
 
137
179
  public dispatchEvent(event: any): any {
138
- super.dispatchEvent(event);
180
+ const dispatched = super.dispatchEvent(event);
181
+
182
+ // the page can hang a handler off the target for any of our events, as a
183
+ // -<event-type> property or an attribute of the same name
139
184
  const ele = event.target;
140
- if (ele) {
141
- // lookup events with - prefix and try to invoke them
142
- const eventFire = (ele as any)['-' + event.type];
143
- if (eventFire) {
144
- return eventFire(event);
145
- } else {
146
- const func = new Function(
147
- 'event',
148
- `
149
- with(document) {
150
- with(this) {
151
- let handler = ${ele.getAttribute('-' + event.type)};
152
- if(typeof handler === 'function') {
153
- handler(event);
154
- }
155
- }
156
- }
157
- `
158
- );
159
- return func.call(ele, event);
160
- }
185
+ if (!ele) {
186
+ return dispatched;
187
+ }
188
+
189
+ const eventFire = (ele as any)['-' + event.type];
190
+ if (eventFire) {
191
+ return eventFire(event);
161
192
  }
193
+
194
+ const inline = ele.getAttribute ? ele.getAttribute('-' + event.type) : null;
195
+ if (!inline) {
196
+ return dispatched;
197
+ }
198
+
199
+ // the compiled handler has no return value of its own, so handing its
200
+ // undefined back would read as a cancelled event - what the caller wants
201
+ // to know is whether anything called preventDefault
202
+ compileInlineHandler(inline).call(ele, event);
203
+ return dispatched;
162
204
  }
163
205
 
164
206
  public closestElement(selector: string, base: Element = this) {
@@ -59,6 +59,10 @@ export enum Events {
59
59
  TICKET_OPENED = 'ticket_opened',
60
60
  TICKET_REOPENED = 'ticket_reopened',
61
61
  TICKET_TOPIC_CHANGED = 'ticket_topic_changed',
62
+ // ephemeral, drives the chat's typing indicator instead of recording
63
+ // history - published by agents as they compose and never persisted
64
+ TYPING_STARTED = 'typing_started',
65
+ TYPING_STOPPED = 'typing_stopped',
62
66
  WARNING = 'warning',
63
67
  WEBHOOK_CALLED = 'webhook_called'
64
68
  }
@@ -58,6 +58,8 @@ import { Dialog } from '../layout/Dialog';
58
58
  import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
59
59
  import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
60
60
  import { FlowSearch, SearchResult } from './FlowSearch';
61
+ import { RealtimeSubscription, subscribeToFlow } from '../live/Realtime';
62
+ import { FlowDependency } from './dependencies';
61
63
 
62
64
  export function findNodeForExit(
63
65
  definition: FlowDefinition,
@@ -105,6 +107,10 @@ const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
105
107
  // Used in both the CSS animation and the JS setTimeout.
106
108
  const PENDING_SAVE_DELAY = 5000;
107
109
 
110
+ // Shortest gap (in ms) between activity reads, however many changes the flow
111
+ // socket announces in that window.
112
+ const ACTIVITY_FETCH_INTERVAL = 3000;
113
+
108
114
  /**
109
115
  * Manages a timed "pending changes" card that lets users discard or auto-save
110
116
  * after a delay. Used for both auto-layout and shift+drag copy.
@@ -200,8 +206,17 @@ export class Editor extends RapidElement {
200
206
  @property({ type: Array })
201
207
  public features: string[] = [];
202
208
 
203
- private activityTimer: number | null = null;
204
- private activityInterval = 100; // Start with 100ms interval for fast initial load
209
+ private activityWatch: RealtimeSubscription = null;
210
+ private watchedActivityFlow: string = null;
211
+ private activityFetchTimer: number | null = null;
212
+ private activityFetchInFlight = false;
213
+ private activityFetchQueued = false;
214
+ private activityFetchToken = 0;
215
+ private assetWatch: RealtimeSubscription = null;
216
+ private watchedAssetStore: Store = null;
217
+ private flowInfoWatch: () => void = null;
218
+ private assetResolveVersion = 0;
219
+ private watchedAssetSignature = '';
205
220
 
206
221
  @fromStore(zustand, (state: AppState) => state.flowDefinition)
207
222
  public definition!: FlowDefinition;
@@ -1198,10 +1213,17 @@ export class Editor extends RapidElement {
1198
1213
  this.zoomManager = new ZoomManager(this);
1199
1214
  }
1200
1215
 
1216
+ connectedCallback(): void {
1217
+ super.connectedCallback();
1218
+ this.syncAssetWatch(zustand.getState().flowInfo);
1219
+ this.syncActivityWatch();
1220
+ }
1221
+
1201
1222
  protected firstUpdated(
1202
1223
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
1203
1224
  ): void {
1204
1225
  super.firstUpdated(changes);
1226
+ this.syncAssetWatch(zustand.getState().flowInfo);
1205
1227
  this.plumber = new Plumber(this.querySelector('#canvas'), this);
1206
1228
  this.plumber.zoom = this.zoom;
1207
1229
  this.setupGlobalEventListeners();
@@ -1422,23 +1444,15 @@ export class Editor extends RapidElement {
1422
1444
  // defer to avoid triggering a reactive canvasSize update during this cycle
1423
1445
  setTimeout(() => this.updateCanvasSize(), 0);
1424
1446
 
1425
- // Start fetching activity data when definition is loaded
1426
- if (this.definition?.uuid) {
1427
- this.startActivityFetching();
1428
- }
1447
+ this.syncActivityWatch();
1429
1448
  }
1430
1449
 
1431
1450
  if (changes.has('simulatorActive')) {
1432
1451
  if (this.simulatorActive) {
1433
1452
  // Close any open floating windows when simulator opens
1434
1453
  this.closeFloatingWindows();
1435
- // Stop polling when simulator becomes active
1436
- this.stopActivityFetching();
1437
- } else {
1438
- // Resume polling and refresh activity when simulator closes
1439
- this.activityInterval = 100; // Reset to fast initial interval
1440
- this.startActivityFetching();
1441
1454
  }
1455
+ this.syncActivityWatch();
1442
1456
  }
1443
1457
 
1444
1458
  if (changes.has('activityData')) {
@@ -1653,55 +1667,188 @@ export class Editor extends RapidElement {
1653
1667
  });
1654
1668
  }
1655
1669
 
1656
- private startActivityFetching(): void {
1657
- // Don't start if simulator is active
1658
- if (this.simulatorActive) {
1670
+ private syncActivityWatch(): void {
1671
+ const flow = this.simulatorActive ? null : this.definition?.uuid;
1672
+ if (this.activityWatch && flow === this.watchedActivityFlow) {
1673
+ return;
1674
+ }
1675
+
1676
+ if (this.activityWatch) {
1677
+ this.activityWatch.unsubscribe();
1678
+ this.activityWatch = null;
1679
+ }
1680
+ this.clearActivityFetch();
1681
+ this.watchedActivityFlow = flow;
1682
+ if (!flow) {
1659
1683
  return;
1660
1684
  }
1661
- // Fetch immediately
1662
- this.fetchActivityData();
1685
+
1686
+ this.activityWatch = subscribeToFlow(
1687
+ flow,
1688
+ (event) => {
1689
+ if (event?.type === 'activity') {
1690
+ this.scheduleActivityFetch(flow);
1691
+ }
1692
+ },
1693
+ // fires on every (re)subscribe, so catch up on anything published while
1694
+ // we were disconnected
1695
+ () => this.scheduleActivityFetch(flow)
1696
+ );
1697
+
1698
+ // the socket only tells us about *changes*, and it may never connect at
1699
+ // all, so the first read doesn't wait on it
1700
+ this.fetchActivityData(flow);
1663
1701
  }
1664
1702
 
1665
- private stopActivityFetching(): void {
1666
- if (this.activityTimer !== null) {
1667
- clearTimeout(this.activityTimer);
1668
- this.activityTimer = null;
1703
+ /**
1704
+ * Abandons any scheduled or in-flight read. The token makes the in-flight
1705
+ * one a no-op when it lands, so a read for the flow we've stopped watching
1706
+ * can neither hold up nor swallow the next flow's first read.
1707
+ */
1708
+ private clearActivityFetch(): void {
1709
+ if (this.activityFetchTimer !== null) {
1710
+ clearTimeout(this.activityFetchTimer);
1711
+ this.activityFetchTimer = null;
1669
1712
  }
1713
+ this.activityFetchQueued = false;
1714
+ this.activityFetchInFlight = false;
1715
+ this.activityFetchToken++;
1670
1716
  }
1671
1717
 
1672
- private fetchActivityData(): void {
1673
- if (!this.definition?.uuid) {
1718
+ /**
1719
+ * Coalesces activity publications into at most one request per window.
1720
+ * Mailroom publishes once per committed sprint batch, so a flow running
1721
+ * against a large group can otherwise announce many changes a second.
1722
+ */
1723
+ private scheduleActivityFetch(flow: string): void {
1724
+ if (flow !== this.watchedActivityFlow || this.activityFetchTimer !== null) {
1674
1725
  return;
1675
1726
  }
1727
+ this.activityFetchTimer = window.setTimeout(() => {
1728
+ this.activityFetchTimer = null;
1729
+ this.fetchActivityData(flow);
1730
+ }, ACTIVITY_FETCH_INTERVAL);
1731
+ }
1676
1732
 
1677
- // Don't fetch if simulator is active
1678
- if (this.simulatorActive) {
1733
+ private fetchActivityData(flow: string): void {
1734
+ if (
1735
+ !this.isConnected ||
1736
+ this.simulatorActive ||
1737
+ flow !== this.watchedActivityFlow
1738
+ ) {
1679
1739
  return;
1680
1740
  }
1681
1741
 
1682
- const activityEndpoint = `/flow/activity/${this.definition.uuid}/`;
1683
1742
  const store = getStore();
1684
1743
  if (!store) {
1685
1744
  return;
1686
1745
  }
1687
- const state = store.getState();
1688
- state.fetchActivity(activityEndpoint).then(() => {
1689
- // Guard against responses arriving after the editor is disconnected
1690
- if (!this.isConnected) {
1746
+
1747
+ // one request at a time, so responses can't land out of order and a burst
1748
+ // of publications collapses into a single follow-up read
1749
+ if (this.activityFetchInFlight) {
1750
+ this.activityFetchQueued = true;
1751
+ return;
1752
+ }
1753
+ this.activityFetchInFlight = true;
1754
+ const token = ++this.activityFetchToken;
1755
+ const done = () => {
1756
+ // a flow change (or teardown) since this read started has already
1757
+ // reset the bookkeeping for the flow we now care about
1758
+ if (token !== this.activityFetchToken) {
1691
1759
  return;
1692
1760
  }
1761
+ this.activityFetchInFlight = false;
1762
+ if (this.activityFetchQueued) {
1763
+ this.activityFetchQueued = false;
1764
+ this.scheduleActivityFetch(flow);
1765
+ }
1766
+ };
1767
+ Promise.resolve(
1768
+ store.getState().fetchActivity(`/flow/activity/${flow}/`)
1769
+ ).then(done, done);
1770
+ }
1771
+
1772
+ private syncAssetNames(): void {
1773
+ const store = getStore();
1774
+ const state = zustand.getState();
1775
+ if (!store || !state.flowDefinition || !state.flowInfo) {
1776
+ return;
1777
+ }
1693
1778
 
1694
- // Schedule next fetch with exponential backoff (max 5 minutes)
1695
- this.activityInterval = Math.min(60000 * 5, this.activityInterval + 100);
1779
+ const canonical: FlowDependency[] = [];
1780
+ for (const dependency of state.flowInfo.dependencies || []) {
1781
+ const identity = dependency.uuid || dependency.key;
1782
+ if (!identity) {
1783
+ continue;
1784
+ }
1785
+ const asset = store.getAsset(dependency.type, identity);
1786
+ if (asset) {
1787
+ canonical.push({ ...dependency, name: asset.name });
1788
+ }
1789
+ }
1790
+ state.updateDependencyNames(canonical);
1791
+ }
1696
1792
 
1697
- if (this.activityTimer !== null) {
1698
- clearTimeout(this.activityTimer);
1793
+ private async resolveAssetNames(info: AppState['flowInfo']): Promise<void> {
1794
+ const store = getStore();
1795
+ if (!store || !info) {
1796
+ return;
1797
+ }
1798
+ const version = ++this.assetResolveVersion;
1799
+ try {
1800
+ const canonical = await store.resolveAssets(info.dependencies || []);
1801
+ if (version === this.assetResolveVersion && this.isConnected) {
1802
+ zustand.getState().updateDependencyNames(canonical);
1699
1803
  }
1804
+ } catch (error) {
1805
+ console.error('failed to resolve flow dependency assets', error);
1806
+ }
1807
+ }
1700
1808
 
1701
- this.activityTimer = window.setTimeout(() => {
1702
- this.fetchActivityData();
1703
- }, this.activityInterval);
1704
- });
1809
+ private syncAssetWatch(info: AppState['flowInfo']): void {
1810
+ if (!this.isConnected) {
1811
+ return;
1812
+ }
1813
+ const store = getStore();
1814
+ if (!store) {
1815
+ return;
1816
+ }
1817
+ const interests = info?.dependencies || [];
1818
+ const signature = interests
1819
+ .map(
1820
+ (dependency) =>
1821
+ `${dependency.type}:${dependency.uuid || dependency.key || ''}`
1822
+ )
1823
+ .sort()
1824
+ .join('|');
1825
+ // the store is part of the key: a replaced <temba-store> leaves us
1826
+ // watching a dead one otherwise
1827
+ if (
1828
+ this.assetWatch &&
1829
+ store === this.watchedAssetStore &&
1830
+ signature === this.watchedAssetSignature
1831
+ ) {
1832
+ return;
1833
+ }
1834
+ if (this.assetWatch) {
1835
+ this.assetWatch.unsubscribe();
1836
+ }
1837
+ this.watchedAssetStore = store;
1838
+ this.watchedAssetSignature = signature;
1839
+ // the store caches the changed asset before publishing, so applying
1840
+ // everything it has covers both a single change and a reconnect refresh
1841
+ this.assetWatch = store.watchAssets(interests, () => this.syncAssetNames());
1842
+ if (!this.flowInfoWatch) {
1843
+ this.flowInfoWatch = zustand.subscribe(
1844
+ (state) => state.flowInfo,
1845
+ (info) => {
1846
+ this.syncAssetWatch(info);
1847
+ this.resolveAssetNames(info);
1848
+ }
1849
+ );
1850
+ this.resolveAssetNames(zustand.getState().flowInfo);
1851
+ }
1705
1852
  }
1706
1853
 
1707
1854
  private handleLanguageChange(languageCode: string): void {
@@ -1957,6 +2104,23 @@ export class Editor extends RapidElement {
1957
2104
 
1958
2105
  disconnectedCallback(): void {
1959
2106
  super.disconnectedCallback();
2107
+ if (this.assetWatch) {
2108
+ this.assetWatch.unsubscribe();
2109
+ this.assetWatch = null;
2110
+ }
2111
+ if (this.activityWatch) {
2112
+ this.activityWatch.unsubscribe();
2113
+ this.activityWatch = null;
2114
+ }
2115
+ this.clearActivityFetch();
2116
+ this.watchedActivityFlow = null;
2117
+ this.watchedAssetStore = null;
2118
+ this.watchedAssetSignature = '';
2119
+ if (this.flowInfoWatch) {
2120
+ this.flowInfoWatch();
2121
+ this.flowInfoWatch = null;
2122
+ }
2123
+ this.assetResolveVersion++;
1960
2124
  this.zoomManager.teardownLoupe();
1961
2125
  getStore()?.getState().setFlushSave(null);
1962
2126
  this.dragManager.teardownListeners();
@@ -1969,10 +2133,6 @@ export class Editor extends RapidElement {
1969
2133
  clearTimeout(this.saveTimer);
1970
2134
  this.saveTimer = null;
1971
2135
  }
1972
- if (this.activityTimer !== null) {
1973
- clearTimeout(this.activityTimer);
1974
- this.activityTimer = null;
1975
- }
1976
2136
  this.pendingTimer.clearTimer();
1977
2137
  window.removeEventListener('resize', this.boundWindowResize);
1978
2138