@nyaruka/temba-components 0.135.9 → 0.136.1

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/demo/components/webchat/example.html +4 -2
  3. package/dist/static/svg/index.svg +1 -1
  4. package/dist/temba-components.js +1351 -322
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/Icons.js +2 -1
  7. package/out-tsc/src/Icons.js.map +1 -1
  8. package/out-tsc/src/display/FloatingTab.js +2 -6
  9. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  10. package/out-tsc/src/flow/CanvasNode.js +29 -1
  11. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  12. package/out-tsc/src/flow/Editor.js +229 -5
  13. package/out-tsc/src/flow/Editor.js.map +1 -1
  14. package/out-tsc/src/flow/Plumber.js +320 -1
  15. package/out-tsc/src/flow/Plumber.js.map +1 -1
  16. package/out-tsc/src/interfaces.js +1 -0
  17. package/out-tsc/src/interfaces.js.map +1 -1
  18. package/out-tsc/src/layout/FloatingWindow.js +30 -8
  19. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  20. package/out-tsc/src/simulator/Simulator.js +1861 -0
  21. package/out-tsc/src/simulator/Simulator.js.map +1 -0
  22. package/out-tsc/src/store/AppState.js +66 -0
  23. package/out-tsc/src/store/AppState.js.map +1 -1
  24. package/out-tsc/src/utils.js +48 -0
  25. package/out-tsc/src/utils.js.map +1 -1
  26. package/out-tsc/temba-modules.js +2 -0
  27. package/out-tsc/temba-modules.js.map +1 -1
  28. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  29. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  30. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  31. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  32. package/out-tsc/test/temba-flow-editor.test.js +262 -1
  33. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  34. package/out-tsc/test/temba-flow-plumber-connections.test.js +3 -1
  35. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  36. package/out-tsc/test/temba-flow-plumber.test.js +3 -1
  37. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  38. package/out-tsc/test/temba-simulator.test.js +642 -0
  39. package/out-tsc/test/temba-simulator.test.js.map +1 -0
  40. package/out-tsc/test/utils.test.js +1 -1
  41. package/out-tsc/test/utils.test.js.map +1 -1
  42. package/package.json +1 -1
  43. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  44. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  45. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  46. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  47. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  50. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  51. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  52. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  53. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  54. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  55. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  56. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  57. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  58. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  59. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  60. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  61. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  62. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  63. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  64. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  65. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  66. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  67. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  68. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  69. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  70. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  71. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  72. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  73. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  74. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  75. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  76. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  77. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  78. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  79. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  80. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  81. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  83. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  84. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  85. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  86. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  87. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  88. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  89. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  90. package/screenshots/truth/floating-tab/gray.png +0 -0
  91. package/screenshots/truth/floating-tab/green.png +0 -0
  92. package/screenshots/truth/floating-tab/purple.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  106. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  114. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  115. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  116. package/screenshots/truth/simulator/after-reset.png +0 -0
  117. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  118. package/screenshots/truth/simulator/context-expanded.png +0 -0
  119. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  120. package/screenshots/truth/simulator/event-info.png +0 -0
  121. package/screenshots/truth/simulator/image-attachment.png +0 -0
  122. package/screenshots/truth/simulator/open-initial.png +0 -0
  123. package/screenshots/truth/simulator/quick-replies.png +0 -0
  124. package/src/Icons.ts +2 -1
  125. package/src/display/FloatingTab.ts +2 -7
  126. package/src/flow/CanvasNode.ts +30 -1
  127. package/src/flow/Editor.ts +246 -4
  128. package/src/flow/Plumber.ts +371 -2
  129. package/src/interfaces.ts +2 -1
  130. package/src/layout/FloatingWindow.ts +37 -12
  131. package/src/simulator/Simulator.ts +2061 -0
  132. package/src/store/AppState.ts +109 -0
  133. package/src/utils.ts +53 -0
  134. package/static/svg/index.svg +1 -1
  135. package/static/svg/work/traced/route.svg +1 -0
  136. package/static/svg/work/used/route.svg +3 -0
  137. package/temba-modules.ts +2 -0
  138. package/test/temba-appstate-node-sorting.test.ts +506 -0
  139. package/test/temba-floating-tab.test.ts +0 -11
  140. package/test/temba-flow-editor.test.ts +298 -1
  141. package/test/temba-flow-plumber-connections.test.ts +4 -1
  142. package/test/temba-flow-plumber.test.ts +4 -1
  143. package/test/temba-simulator.test.ts +866 -0
  144. package/test/utils.test.ts +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  INTERCEPT_BEFORE_DETACH,
13
13
  EVENT_CONNECTION_DETACHED
14
14
  } from '@jsplumb/browser-ui';
15
+ import { getStore } from '../store/Store';
15
16
 
16
17
  const CONNECTOR_DEFAULTS = {
17
18
  type: FlowchartConnector.type,
@@ -79,8 +80,17 @@ export class Plumber {
79
80
  private connectionListeners = new Map();
80
81
  public connectionDragging = false;
81
82
  private connectionWait = null;
82
-
83
- constructor(canvas: HTMLElement) {
83
+ private activityData: { segments: { [key: string]: number } } | null = null;
84
+ private hoveredActivityKey: string | null = null;
85
+ private recentContactsPopup: HTMLElement | null = null;
86
+ private recentContactsCache: { [key: string]: any[] } = {};
87
+ private pendingFetches: { [key: string]: AbortController } = {};
88
+ private hideContactsTimeout: number | null = null;
89
+ private showContactsTimeout: number | null = null;
90
+ private editor: any;
91
+
92
+ constructor(canvas: HTMLElement, editor: any) {
93
+ this.editor = editor;
84
94
  ready(() => {
85
95
  this.jsPlumb = newInstance({
86
96
  container: canvas,
@@ -210,6 +220,365 @@ export class Plumber {
210
220
  this.processPendingConnections();
211
221
  }
212
222
 
223
+ public setActivityData(
224
+ activityData: { segments: { [key: string]: number } } | null
225
+ ) {
226
+ this.activityData = activityData;
227
+ // Clear recent contacts cache when activity data changes
228
+ this.clearRecentContactsCache();
229
+ this.updateActivityOverlays();
230
+ }
231
+
232
+ private updateActivityOverlays() {
233
+ if (!this.jsPlumb || !this.activityData) {
234
+ return;
235
+ }
236
+
237
+ // Get all connections
238
+ const connections = this.jsPlumb.getConnections();
239
+
240
+ connections.forEach((connection: any) => {
241
+ // Get the source exit element
242
+ const sourceElement = connection.source;
243
+ if (!sourceElement) {
244
+ return;
245
+ }
246
+
247
+ // Get destination node
248
+ const targetElement = connection.target;
249
+ if (!targetElement) {
250
+ return;
251
+ }
252
+
253
+ // Create activity key: exitUuid:destinationUuid
254
+ const exitUuid = sourceElement.id;
255
+ const destinationUuid = targetElement.id;
256
+ const activityKey = `${exitUuid}:${destinationUuid}`;
257
+
258
+ // Get activity count for this segment
259
+ const count = this.activityData.segments[activityKey];
260
+
261
+ // Remove existing activity overlays
262
+ connection.removeOverlay('activity-label');
263
+
264
+ // Add new overlay if there's activity
265
+ if (count && count > 0) {
266
+ const overlay = connection.addOverlay({
267
+ type: 'Label',
268
+ options: {
269
+ label: count.toLocaleString(),
270
+ id: 'activity-label',
271
+ cssClass: 'activity-overlay',
272
+ location: 20 // Fixed pixel distance from the start (exit point)
273
+ }
274
+ });
275
+
276
+ // Add hover events for recent contacts popup
277
+ // Use setTimeout to ensure the overlay is fully rendered
278
+ setTimeout(() => {
279
+ // Try multiple ways to get the overlay element
280
+ let overlayElement =
281
+ overlay.canvas || overlay.element || overlay.getElement?.();
282
+
283
+ // If still not found, query the DOM directly
284
+ if (!overlayElement) {
285
+ const overlays = connection.getOverlays();
286
+ if (Array.isArray(overlays)) {
287
+ for (const ovl of overlays) {
288
+ if (ovl.id === 'activity-label') {
289
+ overlayElement =
290
+ ovl.canvas || ovl.element || ovl.getElement?.();
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ // Also try querying by CSS class
298
+ if (!overlayElement && connection.canvas) {
299
+ overlayElement =
300
+ connection.canvas.querySelector('.activity-overlay');
301
+ }
302
+
303
+ if (overlayElement) {
304
+ overlayElement.style.cursor = 'pointer';
305
+ overlayElement.setAttribute('data-activity-key', activityKey);
306
+ overlayElement.addEventListener('mouseenter', () => {
307
+ // Don't show recent contacts when simulator is active
308
+ const store = getStore();
309
+ if (store?.getState().simulatorActive) {
310
+ return;
311
+ }
312
+
313
+ // Get flow UUID from the editor element
314
+ const editor = document.querySelector('temba-flow-editor') as any;
315
+ const flowUuid = editor?.definition?.uuid;
316
+ if (flowUuid) {
317
+ // Start fetching immediately
318
+ this.fetchRecentContacts(activityKey, flowUuid);
319
+
320
+ // But delay showing the popup by half a second
321
+ this.showContactsTimeout = window.setTimeout(() => {
322
+ this.showRecentContacts(activityKey, flowUuid);
323
+ }, 500);
324
+ }
325
+ });
326
+ overlayElement.addEventListener('mouseleave', () => {
327
+ // Cancel the show timeout if still pending
328
+ if (this.showContactsTimeout) {
329
+ clearTimeout(this.showContactsTimeout);
330
+ this.showContactsTimeout = null;
331
+ }
332
+ this.hoveredActivityKey = null;
333
+ this.hideRecentContacts();
334
+ });
335
+ }
336
+ }, 50);
337
+ }
338
+ });
339
+
340
+ // Force repaint to ensure overlays are positioned correctly
341
+ this.repaintEverything();
342
+ }
343
+
344
+ private findOverlayElement(activityKey: string): HTMLElement | null {
345
+ // Find overlay by data attribute
346
+ const overlays = document.querySelectorAll('.activity-overlay');
347
+ for (const overlay of overlays) {
348
+ if (overlay.getAttribute('data-activity-key') === activityKey) {
349
+ return overlay as HTMLElement;
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
355
+ private async fetchRecentContacts(activityKey: string, flowUuid: string) {
356
+ // Skip if already cached or currently fetching
357
+ if (
358
+ this.recentContactsCache[activityKey] ||
359
+ this.pendingFetches[activityKey]
360
+ ) {
361
+ return;
362
+ }
363
+
364
+ // Cancel any pending fetch for this key
365
+ if (this.pendingFetches[activityKey]) {
366
+ this.pendingFetches[activityKey].abort();
367
+ }
368
+
369
+ // Fetch recent contacts from endpoint
370
+ const controller = new AbortController();
371
+ this.pendingFetches[activityKey] = controller;
372
+
373
+ try {
374
+ // Parse exit UUID and destination UUID from activity key
375
+ const [exitUuid, destinationUuid] = activityKey.split(':');
376
+
377
+ const endpoint = `/flow/recent_contacts/${flowUuid}/${exitUuid}/${destinationUuid}/`;
378
+
379
+ const response = await fetch(endpoint, {
380
+ signal: controller.signal
381
+ });
382
+
383
+ if (!response.ok) {
384
+ throw new Error(`HTTP error! status: ${response.status}`);
385
+ }
386
+
387
+ const data = await response.json();
388
+ // API returns array directly, not wrapped in results
389
+ const recentContacts = Array.isArray(data) ? data : data.results || [];
390
+
391
+ // Cache the results
392
+ this.recentContactsCache[activityKey] = recentContacts;
393
+ } catch (error) {
394
+ if ((error as Error).name !== 'AbortError') {
395
+ console.error('Failed to fetch recent contacts:', error);
396
+ }
397
+ } finally {
398
+ delete this.pendingFetches[activityKey];
399
+ }
400
+ }
401
+
402
+ private async showRecentContacts(activityKey: string, flowUuid: string) {
403
+ // Don't show recent contacts when simulator is active
404
+ const store = getStore();
405
+ if (store?.getState().simulatorActive) {
406
+ return;
407
+ }
408
+
409
+ // Find the overlay element fresh to avoid stale references
410
+ const overlayElement = this.findOverlayElement(activityKey);
411
+ if (!overlayElement) {
412
+ console.warn('Could not find overlay element for activity:', activityKey);
413
+ return;
414
+ }
415
+ // Clear any pending hide timeout
416
+ if (this.hideContactsTimeout) {
417
+ clearTimeout(this.hideContactsTimeout);
418
+ this.hideContactsTimeout = null;
419
+ }
420
+
421
+ this.hoveredActivityKey = activityKey;
422
+
423
+ // Create popup if it doesn't exist
424
+ if (!this.recentContactsPopup) {
425
+ this.recentContactsPopup = document.createElement('div');
426
+ this.recentContactsPopup.className = 'recent-contacts-popup';
427
+ // Add inline styles to ensure visibility
428
+ this.recentContactsPopup.style.position = 'absolute';
429
+ this.recentContactsPopup.style.width = '200px';
430
+ this.recentContactsPopup.style.background = '#f3f3f3';
431
+ this.recentContactsPopup.style.borderRadius = '10px';
432
+ this.recentContactsPopup.style.boxShadow =
433
+ '0 1px 3px 1px rgba(130, 130, 130, 0.2)';
434
+ this.recentContactsPopup.style.zIndex = '1015';
435
+ this.recentContactsPopup.style.display = 'none';
436
+ document.body.appendChild(this.recentContactsPopup);
437
+ }
438
+
439
+ // Add hover events to keep popup open (only needs to be done once)
440
+ if (!this.recentContactsPopup.onmouseenter) {
441
+ this.recentContactsPopup.onmouseenter = () => {
442
+ if (this.hideContactsTimeout) {
443
+ clearTimeout(this.hideContactsTimeout);
444
+ this.hideContactsTimeout = null;
445
+ }
446
+ };
447
+ this.recentContactsPopup.onmouseleave = () => {
448
+ this.hoveredActivityKey = null;
449
+ this.hideRecentContacts();
450
+ };
451
+
452
+ // Add click event listener for contact names
453
+ this.recentContactsPopup.onclick = (e: MouseEvent) => {
454
+ const target = e.target as HTMLElement;
455
+
456
+ if (target.classList.contains('contact-name')) {
457
+ this.hideRecentContacts(false);
458
+ const contactUuid = target.getAttribute('data-uuid');
459
+ if (contactUuid) {
460
+ // Fire custom event through editor
461
+ this.editor.fireCustomEvent('temba-contact-clicked', {
462
+ uuid: contactUuid
463
+ });
464
+ }
465
+ }
466
+ };
467
+ }
468
+
469
+ // Check cache first
470
+ if (this.recentContactsCache[activityKey]) {
471
+ this.renderRecentContactsPopup(this.recentContactsCache[activityKey]);
472
+ this.positionPopup(overlayElement);
473
+ } else {
474
+ // Show loading state if data isn't ready yet
475
+ this.recentContactsPopup.innerHTML =
476
+ '<div class="no-contacts-message">Loading...</div>';
477
+ this.positionPopup(overlayElement);
478
+
479
+ // Wait for the fetch to complete
480
+ await this.fetchRecentContacts(activityKey, flowUuid);
481
+
482
+ // Render if still hovering over this activity
483
+ if (this.hoveredActivityKey === activityKey) {
484
+ const contacts = this.recentContactsCache[activityKey] || [];
485
+ this.renderRecentContactsPopup(contacts);
486
+ this.positionPopup(overlayElement);
487
+ }
488
+ }
489
+ }
490
+
491
+ private positionPopup(overlayElement: HTMLElement) {
492
+ if (!this.recentContactsPopup) return;
493
+
494
+ // Position popup near the overlay
495
+ const rect = overlayElement.getBoundingClientRect();
496
+ this.recentContactsPopup.style.left = `${rect.left + window.scrollX}px`;
497
+ this.recentContactsPopup.style.top = `${
498
+ rect.bottom + window.scrollY + 5
499
+ }px`;
500
+
501
+ // Remove inline display style so CSS class can work
502
+ this.recentContactsPopup.style.display = '';
503
+
504
+ // Trigger animation by adding class
505
+ this.recentContactsPopup.classList.remove('show');
506
+ // Force reflow to restart animation
507
+ void this.recentContactsPopup.offsetWidth;
508
+ this.recentContactsPopup.classList.add('show');
509
+ }
510
+
511
+ private renderRecentContactsPopup(recentContacts: any[]) {
512
+ if (!this.recentContactsPopup) return;
513
+
514
+ const hasContacts = recentContacts.length > 0;
515
+
516
+ if (!hasContacts) {
517
+ // Simple message when no contacts
518
+ this.recentContactsPopup.innerHTML =
519
+ '<div class="no-contacts-message">No Recent Contacts</div>';
520
+ return;
521
+ }
522
+
523
+ let html = `<div class="popup-title">Recent Contacts</div>`;
524
+
525
+ recentContacts.forEach((contact: any) => {
526
+ html += `<div class="contact-row">`;
527
+ html += `<div class="contact-name" data-uuid="${contact.contact.uuid}">${contact.contact.name}</div>`;
528
+ if (contact.operand) {
529
+ html += `<div class="contact-operand">${contact.operand}</div>`;
530
+ }
531
+ if (contact.time) {
532
+ const time = new Date(contact.time);
533
+ const now = new Date();
534
+ const diffMs = now.getTime() - time.getTime();
535
+ const diffMins = Math.floor(diffMs / 60000);
536
+ const diffHours = Math.floor(diffMs / 3600000);
537
+ const diffDays = Math.floor(diffMs / 86400000);
538
+
539
+ let timeStr = '';
540
+ if (diffMins < 1) timeStr = 'just now';
541
+ else if (diffMins < 60) timeStr = `${diffMins}m ago`;
542
+ else if (diffHours < 24) timeStr = `${diffHours}h ago`;
543
+ else timeStr = `${diffDays}d ago`;
544
+
545
+ html += `<div class="contact-time">${timeStr}</div>`;
546
+ }
547
+ html += `</div>`;
548
+ });
549
+
550
+ this.recentContactsPopup.innerHTML = html;
551
+ }
552
+
553
+ private hideRecentContacts(wait = true) {
554
+ if (!wait) {
555
+ if (this.recentContactsPopup) {
556
+ this.recentContactsPopup.classList.remove('show');
557
+ this.recentContactsPopup.style.display = 'none';
558
+ this.hoveredActivityKey = null;
559
+ }
560
+ return;
561
+ }
562
+
563
+ this.hideContactsTimeout = window.setTimeout(() => {
564
+ // Check if we're still hovering over an activity
565
+ if (!this.hoveredActivityKey && this.recentContactsPopup) {
566
+ this.recentContactsPopup.classList.remove('show');
567
+ this.recentContactsPopup.style.display = 'none';
568
+ this.hoveredActivityKey = null;
569
+ }
570
+ }, 200); // Small delay to allow moving between overlay and popup
571
+ }
572
+
573
+ public clearRecentContactsCache() {
574
+ this.recentContactsCache = {};
575
+ // Cancel any pending fetches
576
+ Object.values(this.pendingFetches).forEach((controller) =>
577
+ controller.abort()
578
+ );
579
+ this.pendingFetches = {};
580
+ }
581
+
213
582
  public repaintEverything() {
214
583
  if (this.jsPlumb) {
215
584
  this.jsPlumb.repaintEverything();
package/src/interfaces.ts CHANGED
@@ -302,5 +302,6 @@ export enum CustomEventType {
302
302
  ActionEditCanceled = 'temba-action-edit-canceled',
303
303
  NodeEditRequested = 'temba-node-edit-requested',
304
304
  NodeSaved = 'temba-node-saved',
305
- NodeEditCancelled = 'temba-node-edit-cancelled'
305
+ NodeEditCancelled = 'temba-node-edit-cancelled',
306
+ FollowSimulation = 'temba-follow-simulation'
306
307
  }
@@ -32,6 +32,11 @@ export class FloatingWindow extends RapidElement {
32
32
  background: transparent;
33
33
  border-radius: 0;
34
34
  box-shadow: none;
35
+ pointer-events: none;
36
+ }
37
+
38
+ .window.chromeless .body {
39
+ pointer-events: none;
35
40
  }
36
41
 
37
42
  .window.dragging {
@@ -126,6 +131,18 @@ export class FloatingWindow extends RapidElement {
126
131
  @property({ type: String })
127
132
  color = '#6B7280';
128
133
 
134
+ @property({ type: Number })
135
+ leftBoundaryMargin = 0;
136
+
137
+ @property({ type: Number })
138
+ rightBoundaryMargin = 0;
139
+
140
+ @property({ type: Number })
141
+ topBoundaryMargin = 0;
142
+
143
+ @property({ type: Number })
144
+ bottomBoundaryMargin = 0;
145
+
129
146
  private dragStartX = 0;
130
147
  private dragStartY = 0;
131
148
  private dragOffsetX = 0;
@@ -221,7 +238,7 @@ export class FloatingWindow extends RapidElement {
221
238
  }
222
239
  }
223
240
 
224
- private handleClose() {
241
+ public handleClose() {
225
242
  this.hidden = true;
226
243
  // show all tabs when window is closed
227
244
  FloatingTab.showAllTabs();
@@ -258,8 +275,11 @@ export class FloatingWindow extends RapidElement {
258
275
  // keep window within viewport bounds with 20px padding
259
276
  const padding = 20;
260
277
  this.left = Math.max(
261
- padding,
262
- Math.min(this.left, window.innerWidth - this.width - padding)
278
+ padding - this.leftBoundaryMargin,
279
+ Math.min(
280
+ this.left,
281
+ window.innerWidth - this.width - padding + this.rightBoundaryMargin
282
+ )
263
283
  );
264
284
 
265
285
  // get the actual rendered height of the window element
@@ -269,10 +289,13 @@ export class FloatingWindow extends RapidElement {
269
289
  const currentHeight =
270
290
  windowElement?.offsetHeight || this.maxHeight || window.innerHeight;
271
291
  const maxTop = Math.max(
272
- padding,
273
- window.innerHeight - currentHeight - padding
292
+ padding - this.topBoundaryMargin,
293
+ window.innerHeight - currentHeight - padding + this.bottomBoundaryMargin
294
+ );
295
+ this.top = Math.max(
296
+ padding - this.topBoundaryMargin,
297
+ Math.min(this.top, maxTop)
274
298
  );
275
- this.top = Math.max(padding, Math.min(this.top, maxTop));
276
299
  };
277
300
 
278
301
  private handleMouseUp = () => {
@@ -297,11 +320,13 @@ export class FloatingWindow extends RapidElement {
297
320
 
298
321
  // if positioned from right, always recalculate from right edge
299
322
  if (this.positionFromRight) {
300
- this.left = window.innerWidth - this.width - padding;
323
+ this.left =
324
+ window.innerWidth - this.width - padding + this.rightBoundaryMargin;
301
325
  } else {
302
326
  // only adjust left if out of bounds
303
- const minLeft = padding;
304
- const maxLeft = window.innerWidth - this.width - padding;
327
+ const minLeft = padding - this.leftBoundaryMargin;
328
+ const maxLeft =
329
+ window.innerWidth - this.width - padding + this.rightBoundaryMargin;
305
330
 
306
331
  if (this.left < minLeft) {
307
332
  this.left = minLeft;
@@ -311,10 +336,10 @@ export class FloatingWindow extends RapidElement {
311
336
  }
312
337
 
313
338
  // only adjust top if out of bounds
314
- const minTop = padding;
339
+ const minTop = padding - this.topBoundaryMargin;
315
340
  const maxTop = Math.max(
316
- padding,
317
- window.innerHeight - currentHeight - padding
341
+ padding - this.topBoundaryMargin,
342
+ window.innerHeight - currentHeight - padding + this.bottomBoundaryMargin
318
343
  );
319
344
 
320
345
  if (this.top < minTop) {