@nyaruka/temba-components 0.134.6 → 0.136.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.
Files changed (76) hide show
  1. package/.github/workflows/publish.yml +16 -8
  2. package/CHANGELOG.md +84 -0
  3. package/demo/components/webchat/example.html +4 -2
  4. package/dist/static/svg/index.svg +1 -1
  5. package/dist/temba-components.js +1346 -334
  6. package/dist/temba-components.js.map +1 -1
  7. package/out-tsc/src/Icons.js +2 -1
  8. package/out-tsc/src/Icons.js.map +1 -1
  9. package/out-tsc/src/display/Chat.js +7 -2
  10. package/out-tsc/src/display/Chat.js.map +1 -1
  11. package/out-tsc/src/display/Thumbnail.js +65 -8
  12. package/out-tsc/src/display/Thumbnail.js.map +1 -1
  13. package/out-tsc/src/flow/CanvasNode.js +11 -0
  14. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  15. package/out-tsc/src/flow/Editor.js +224 -2
  16. package/out-tsc/src/flow/Editor.js.map +1 -1
  17. package/out-tsc/src/flow/Plumber.js +320 -1
  18. package/out-tsc/src/flow/Plumber.js.map +1 -1
  19. package/out-tsc/src/interfaces.js +1 -0
  20. package/out-tsc/src/interfaces.js.map +1 -1
  21. package/out-tsc/src/layout/FloatingWindow.js +30 -8
  22. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  23. package/out-tsc/src/simulator/Simulator.js +1827 -0
  24. package/out-tsc/src/simulator/Simulator.js.map +1 -0
  25. package/out-tsc/src/store/AppState.js +33 -0
  26. package/out-tsc/src/store/AppState.js.map +1 -1
  27. package/out-tsc/src/utils.js +55 -6
  28. package/out-tsc/src/utils.js.map +1 -1
  29. package/out-tsc/temba-modules.js +2 -0
  30. package/out-tsc/temba-modules.js.map +1 -1
  31. package/out-tsc/test/temba-flow-editor.test.js +1 -1
  32. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  33. package/out-tsc/test/temba-flow-plumber-connections.test.js +3 -1
  34. package/out-tsc/test/temba-flow-plumber-connections.test.js.map +1 -1
  35. package/out-tsc/test/temba-flow-plumber.test.js +3 -1
  36. package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
  37. package/out-tsc/test/temba-simulator.test.js +642 -0
  38. package/out-tsc/test/temba-simulator.test.js.map +1 -0
  39. package/out-tsc/test/temba-thumbnail.test.js +120 -0
  40. package/out-tsc/test/temba-thumbnail.test.js.map +1 -0
  41. package/out-tsc/test/temba-utils-index.test.js +12 -6
  42. package/out-tsc/test/temba-utils-index.test.js.map +1 -1
  43. package/out-tsc/test/utils.test.js +1 -1
  44. package/out-tsc/test/utils.test.js.map +1 -1
  45. package/package.json +1 -1
  46. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  47. package/screenshots/truth/simulator/after-reset.png +0 -0
  48. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  49. package/screenshots/truth/simulator/context-expanded.png +0 -0
  50. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  51. package/screenshots/truth/simulator/event-info.png +0 -0
  52. package/screenshots/truth/simulator/image-attachment.png +0 -0
  53. package/screenshots/truth/simulator/open-initial.png +0 -0
  54. package/screenshots/truth/simulator/quick-replies.png +0 -0
  55. package/src/Icons.ts +2 -1
  56. package/src/display/Chat.ts +10 -1
  57. package/src/display/Thumbnail.ts +67 -8
  58. package/src/flow/CanvasNode.ts +12 -0
  59. package/src/flow/Editor.ts +240 -1
  60. package/src/flow/Plumber.ts +371 -2
  61. package/src/interfaces.ts +2 -1
  62. package/src/layout/FloatingWindow.ts +36 -11
  63. package/src/simulator/Simulator.ts +2008 -0
  64. package/src/store/AppState.ts +53 -0
  65. package/src/utils.ts +59 -6
  66. package/static/svg/index.svg +1 -1
  67. package/static/svg/work/traced/route.svg +1 -0
  68. package/static/svg/work/used/route.svg +3 -0
  69. package/temba-modules.ts +2 -0
  70. package/test/temba-flow-editor.test.ts +1 -1
  71. package/test/temba-flow-plumber-connections.test.ts +4 -1
  72. package/test/temba-flow-plumber.test.ts +4 -1
  73. package/test/temba-simulator.test.ts +866 -0
  74. package/test/temba-thumbnail.test.ts +150 -0
  75. package/test/temba-utils-index.test.ts +14 -6
  76. package/test/utils.test.ts +1 -1
@@ -0,0 +1,1827 @@
1
+ import { __decorate } from "tslib";
2
+ import { html } from 'lit-html';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { css } from 'lit';
5
+ import { property } from 'lit/decorators.js';
6
+ import { postJSON, fromCookie } from '../utils';
7
+ import { getStore } from '../store/Store';
8
+ import { CustomEventType } from '../interfaces';
9
+ // test attachment URLs
10
+ const TEST_IMAGES = [
11
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
12
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_b.jpg',
13
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_c.jpg',
14
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_d.jpg'
15
+ ];
16
+ const TEST_VIDEOS = [
17
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_video_a.mp4'
18
+ ];
19
+ const TEST_AUDIO = [
20
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_audio_a.mp3'
21
+ ];
22
+ const TEST_LOCATIONS = [
23
+ 'geo:47.6062,-122.3321', // Seattle
24
+ 'geo:-0.1807,-78.4678', // Quito
25
+ 'geo:-2.9001,-79.0059', // Cuenca
26
+ 'geo:-1.9536,30.0606' // Kigali
27
+ ];
28
+ const SIMULATOR_SIZES = {
29
+ small: {
30
+ phoneWidth: 270,
31
+ phoneHeight: 576,
32
+ phoneTotalHeight: 576,
33
+ phoneScreenHeight: 376,
34
+ contextWidth: 336,
35
+ contextHeight: 416,
36
+ contextOffset: 48,
37
+ optionPaneWidth: 44,
38
+ optionPaneGap: 10,
39
+ windowPadding: 24,
40
+ cutoutHeight: 32,
41
+ cutoutPadding: 12,
42
+ cutoutFontSize: 10,
43
+ cutoutIslandWidth: 80,
44
+ cutoutIslandHeight: 20,
45
+ cutoutIslandTop: 6
46
+ },
47
+ medium: {
48
+ phoneWidth: 300,
49
+ phoneHeight: 720,
50
+ phoneTotalHeight: 720,
51
+ phoneScreenHeight: 470,
52
+ contextWidth: 420,
53
+ contextHeight: 520,
54
+ contextOffset: 60,
55
+ optionPaneWidth: 44,
56
+ optionPaneGap: 12,
57
+ windowPadding: 30,
58
+ cutoutHeight: 40,
59
+ cutoutPadding: 16,
60
+ cutoutFontSize: 12,
61
+ cutoutIslandWidth: 100,
62
+ cutoutIslandHeight: 24,
63
+ cutoutIslandTop: 8
64
+ },
65
+ large: {
66
+ phoneWidth: 360,
67
+ phoneHeight: 864,
68
+ phoneTotalHeight: 864,
69
+ phoneScreenHeight: 564,
70
+ contextWidth: 504,
71
+ contextHeight: 624,
72
+ contextOffset: 72,
73
+ optionPaneWidth: 44,
74
+ optionPaneGap: 14,
75
+ windowPadding: 36,
76
+ cutoutHeight: 50,
77
+ cutoutPadding: 20,
78
+ cutoutFontSize: 14,
79
+ cutoutIslandWidth: 120,
80
+ cutoutIslandHeight: 30,
81
+ cutoutIslandTop: 10
82
+ }
83
+ };
84
+ export class Simulator extends RapidElement {
85
+ constructor() {
86
+ super(...arguments);
87
+ this.flow = '';
88
+ this.endpoint = '';
89
+ this.animationTime = 200;
90
+ this.events = [];
91
+ this.previousEventCount = 0;
92
+ this.session = null;
93
+ this.context = null;
94
+ this.contact = {
95
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
96
+ urns: ['tel:+12065551212'],
97
+ fields: {},
98
+ groups: [],
99
+ language: 'eng',
100
+ status: 'active',
101
+ created_on: new Date().toISOString()
102
+ };
103
+ this.sprinting = false;
104
+ this.inputValue = '';
105
+ this.expandedPaths = new Set();
106
+ this.copiedExpression = '';
107
+ this.toastMessage = '';
108
+ this.showAllKeys = true;
109
+ this.previousWindowWidth = 0;
110
+ this.currentQuickReplies = [];
111
+ this.isVisible = false;
112
+ this.attachmentMenuOpen = false;
113
+ this.boundClickOutsideHandler = null;
114
+ // attachment cycling indices - initialized randomly
115
+ this.imageIndex = Math.floor(Math.random() * TEST_IMAGES.length);
116
+ this.videoIndex = Math.floor(Math.random() * TEST_VIDEOS.length);
117
+ this.audioIndex = Math.floor(Math.random() * TEST_AUDIO.length);
118
+ this.locationIndex = Math.floor(Math.random() * TEST_LOCATIONS.length);
119
+ }
120
+ static get styles() {
121
+ return css `
122
+ :host {
123
+ /* size-specific dimensions are set dynamically via inline styles */
124
+ --phone-width: 300px;
125
+ --phone-total-height: 720px;
126
+ --context-width: 420px;
127
+ --context-offset: 60px;
128
+ --option-pane-width: 44px;
129
+ --option-pane-gap: 12px;
130
+ --window-padding: 30px;
131
+ --phone-screen-height: 470px;
132
+ --context-height: 520px;
133
+ --context-closed-left: 332px;
134
+ --animation-time: 200ms;
135
+ }
136
+
137
+ .phone-simulator {
138
+ padding-left: calc(var(--context-width) + var(--context-offset));
139
+ padding-top: var(--window-padding);
140
+ padding-bottom: var(--window-padding);
141
+ position: relative;
142
+ display: flex;
143
+ align-items: flex-start;
144
+ }
145
+
146
+ .option-pane {
147
+ margin-top: var(--window-padding);
148
+ margin-left: var(--option-pane-gap);
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 6px;
152
+ padding: 6px;
153
+ background: rgba(0, 0, 0, 0.7);
154
+ backdrop-filter: blur(10px);
155
+ border-radius: 16px;
156
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
157
+ pointer-events: all;
158
+ }
159
+ .option-btn {
160
+ background: rgba(255, 255, 255, 0.1);
161
+ border: none;
162
+ border-radius: 12px;
163
+ width: 32px;
164
+ height: 32px;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ cursor: pointer;
169
+ transition: all var(--animation-time) ease;
170
+ color: white;
171
+ }
172
+ .option-btn:hover {
173
+ background: rgba(255, 255, 255, 0.2);
174
+ transform: scale(1.05);
175
+ }
176
+ .option-btn:active {
177
+ transform: scale(0.95);
178
+ }
179
+ .option-btn.active {
180
+ background: var(--color-primary-dark);
181
+ color: white;
182
+ }
183
+ .option-btn.active:hover {
184
+ background: var(--color-primary-dark);
185
+ }
186
+
187
+ .phone-frame {
188
+ width: var(--phone-width);
189
+ border-radius: 40px;
190
+ border: 6px solid #1f2937;
191
+ box-shadow: 0 0px 30px rgba(0, 0, 0, 0.4);
192
+ background: #000;
193
+ position: relative;
194
+ overflow: hidden;
195
+ z-index: 2;
196
+ }
197
+
198
+ .context-explorer {
199
+ width: var(--context-width);
200
+ height: var(--context-height);
201
+ border-top-left-radius: 16px;
202
+ border-bottom-left-radius: 16px;
203
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
204
+ position: absolute;
205
+ left: var(--context-closed-left);
206
+ top: calc(var(--window-padding) + 40px);
207
+ z-index: 1;
208
+ font-size: 13px;
209
+ color: #374151;
210
+ transition: left calc(var(--animation-time) * 1.5) ease-out,
211
+ opacity calc(var(--animation-time) * 1.5) ease-out;
212
+ opacity: 0;
213
+ pointer-events: none;
214
+ background: rgba(0, 0, 0, 0.7);
215
+ backdrop-filter: blur(10px);
216
+ display: flex;
217
+ flex-direction: column;
218
+ padding: 12px;
219
+ }
220
+
221
+ .context-gutter {
222
+ background: rgba(0, 0, 0, 0.3);
223
+ border-radius: 6px;
224
+
225
+ display: flex;
226
+ flex-direction: row;
227
+ align-items: center;
228
+ padding: 4px;
229
+ margin-right: 32px;
230
+ margin-top: 8px;
231
+ flex-shrink: 0;
232
+ }
233
+
234
+ .context-gutter-btn {
235
+ width: 14px;
236
+ height: 14px;
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ cursor: pointer;
241
+ border-radius: 6px;
242
+ transition: background var(--animation-time) ease;
243
+ color: rgba(255, 255, 255, 0.6);
244
+ padding: 4px;
245
+ }
246
+
247
+ .context-gutter-btn:hover {
248
+ background: rgba(255, 255, 255, 0.1);
249
+ color: rgba(255, 255, 255, 0.9);
250
+ }
251
+
252
+ .context-gutter-btn.active {
253
+ color: #c084fc;
254
+ }
255
+
256
+ .context-gutter-spacer {
257
+ flex: 1;
258
+ }
259
+
260
+ .context-explorer-scroll {
261
+ scrollbar-color: rgba(255, 255, 255, 0.3) #4a4a4a;
262
+ scrollbar-width: thin;
263
+ height: 100%;
264
+ overflow-y: scroll;
265
+ padding-right: 10px;
266
+ margin-right: 30px;
267
+ flex-grow: 1;
268
+ }
269
+
270
+ .context-explorer-bleed {
271
+ height: 100%;
272
+ width: 0px;
273
+ }
274
+
275
+ .context-explorer-scroll::-webkit-scrollbar {
276
+ width: 18px;
277
+ }
278
+
279
+ .context-explorer-scroll::-webkit-scrollbar-track {
280
+ background: rgba(0, 0, 0, 0.3);
281
+ border-radius: 4px;
282
+ }
283
+
284
+ .context-explorer-scroll::-webkit-scrollbar-thumb {
285
+ background: rgba(255, 255, 255, 0.3);
286
+ border-radius: 4px;
287
+ }
288
+
289
+ .context-explorer-scroll::-webkit-scrollbar-thumb:hover {
290
+ background: rgba(255, 255, 255, 0.5);
291
+ }
292
+
293
+ .context-explorer.open {
294
+ left: var(--context-offset);
295
+ opacity: 1;
296
+ pointer-events: auto;
297
+ }
298
+
299
+ .context-item {
300
+ display: flex;
301
+ align-items: flex-start;
302
+ padding: 2px 4px;
303
+ cursor: pointer;
304
+ user-select: none;
305
+ }
306
+
307
+ .context-item:hover {
308
+ background: rgba(0, 0, 0, 0.05);
309
+ }
310
+
311
+ .context-item-expandable {
312
+ display: flex;
313
+ align-items: center;
314
+ }
315
+
316
+ .context-expand-icon {
317
+ width: 16px;
318
+ display: inline-block;
319
+ text-align: center;
320
+ flex-shrink: 0;
321
+ transition: transform var(--animation-time) ease;
322
+ color: #ffffff;
323
+ }
324
+
325
+ .context-expand-icon.expanded {
326
+ transform: rotate(90deg);
327
+ }
328
+
329
+ .context-key {
330
+ color: #ffffff;
331
+ flex-shrink: 0;
332
+ margin-right: 8px;
333
+ display: flex;
334
+ }
335
+
336
+ .context-key.has-value {
337
+ color: #e8b5e8;
338
+ }
339
+
340
+ .context-value {
341
+ color: #aaa;
342
+ flex: 1;
343
+ text-align: right;
344
+ overflow: hidden;
345
+ text-overflow: ellipsis;
346
+ white-space: nowrap;
347
+ }
348
+
349
+ .context-children {
350
+ margin-left: 16px;
351
+ }
352
+
353
+ .context-copy-icon {
354
+ opacity: 0;
355
+ margin-left: 4px;
356
+ transition: opacity var(--animation-time) ease;
357
+ cursor: pointer;
358
+ color: #ccc;
359
+ }
360
+
361
+ .context-item:hover .context-copy-icon {
362
+ opacity: 1;
363
+ }
364
+
365
+ .context-toast {
366
+ position: absolute;
367
+ bottom: 60px;
368
+ left: 50%;
369
+ transform: translateX(-50%);
370
+ background: #666;
371
+ color: white;
372
+ padding: 12px 12px;
373
+ border-radius: 8px;
374
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
375
+ font-size: 13px;
376
+ z-index: 10;
377
+ animation: slideInUp var(--animation-time) ease-out;
378
+ }
379
+
380
+ .context-toast .expression {
381
+ color: #e8b5e8;
382
+ font-weight: 600;
383
+ }
384
+
385
+ @keyframes slideInUp {
386
+ from {
387
+ opacity: 0;
388
+ transform: translateX(-50%) translateY(20px);
389
+ }
390
+ to {
391
+ opacity: 1;
392
+ transform: translateX(-50%) translateY(0);
393
+ }
394
+ }
395
+
396
+ .phone-top {
397
+ position: absolute;
398
+ top: 0;
399
+ left: 0;
400
+ right: 0;
401
+ z-index: 10;
402
+ cursor: grab;
403
+ }
404
+ .phone-notch {
405
+ background: transparent;
406
+ height: var(--cutout-height);
407
+ position: relative;
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ padding: 0 var(--cutout-padding);
412
+ }
413
+ .phone-notch::before {
414
+ content: '';
415
+ position: absolute;
416
+ top: 0;
417
+ left: 0;
418
+ right: 0;
419
+ height: 100%;
420
+ background: linear-gradient(
421
+ to bottom,
422
+ rgba(0, 0, 0, 0.3) 0%,
423
+ rgba(0, 0, 0, 0.2) 50%,
424
+ transparent 100%
425
+ );
426
+ z-index: -1;
427
+ }
428
+ .dynamic-island {
429
+ top: var(--cutout-island-top);
430
+ left: 50%;
431
+
432
+ width: var(--cutout-island-width);
433
+ height: var(--cutout-island-height);
434
+ background: #000;
435
+ border-radius: calc(var(--cutout-island-height) / 1.5);
436
+ z-index: 1;
437
+ }
438
+ .phone-notch .time {
439
+ color: #000;
440
+ font-size: var(--cutout-font-size);
441
+ font-weight: 600;
442
+ }
443
+ .phone-notch .status-icons {
444
+ display: flex;
445
+ gap: 4px;
446
+ align-items: center;
447
+ }
448
+ .phone-notch .status-icons span {
449
+ color: #000;
450
+ font-size: var(--cutout-font-size);
451
+ }
452
+ .phone-header {
453
+ background: transparent;
454
+ padding: 10px 15px;
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: flex-end;
458
+ cursor: move;
459
+ user-select: none;
460
+ border-bottom: none;
461
+ pointer-events: all;
462
+ }
463
+
464
+ .phone-screen {
465
+ background: white;
466
+ padding: 15px;
467
+ padding-top: calc(var(--cutout-height) + 10px);
468
+ padding-bottom: 60px;
469
+ height: var(--phone-screen-height);
470
+ overflow-y: scroll;
471
+ display: flex;
472
+ flex-direction: column;
473
+ scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
474
+ scrollbar-width: thin;
475
+ }
476
+
477
+ .phone-screen::-webkit-scrollbar {
478
+ width: 8px;
479
+ }
480
+
481
+ .phone-screen::-webkit-scrollbar-track {
482
+ background: transparent;
483
+ }
484
+
485
+ .phone-screen::-webkit-scrollbar-thumb {
486
+ background: rgba(0, 0, 0, 0.2);
487
+ border-radius: 4px;
488
+ }
489
+
490
+ .phone-screen::-webkit-scrollbar-thumb:hover {
491
+ background: rgba(0, 0, 0, 0.3);
492
+ }
493
+
494
+ @keyframes messageAppear {
495
+ 0% {
496
+ opacity: 0;
497
+ transform: scale(0.8);
498
+ }
499
+ 70% {
500
+ opacity: 1;
501
+ transform: scale(1.05);
502
+ }
503
+ 100% {
504
+ opacity: 1;
505
+ transform: scale(1);
506
+ }
507
+ }
508
+
509
+ .message {
510
+ padding: 10px 14px;
511
+ margin-bottom: 8px;
512
+ border-radius: 18px;
513
+ max-width: 70%;
514
+ font-size: 13px;
515
+ line-height: 1.2;
516
+ }
517
+ .message.animated {
518
+ animation: messageAppear var(--animation-time) ease-out forwards;
519
+ opacity: 0;
520
+ }
521
+ .message.incoming {
522
+ background: #e5e5ea;
523
+ color: #000;
524
+ margin-right: auto;
525
+ border-bottom-left-radius: 4px;
526
+ }
527
+ .message.outgoing {
528
+ background: #007aff;
529
+ color: white;
530
+ margin-left: auto;
531
+ text-align: left;
532
+ border-bottom-right-radius: 4px;
533
+ }
534
+ .attachment-wrapper {
535
+ max-width: 70%;
536
+ margin-bottom: 8px;
537
+ display: flex;
538
+ flex-direction: column;
539
+ gap: 4px;
540
+ }
541
+ .attachment-wrapper.incoming {
542
+ margin-right: auto;
543
+ align-items: flex-start;
544
+ }
545
+ .attachment-wrapper.outgoing {
546
+ margin-left: auto;
547
+ align-items: flex-end;
548
+ }
549
+ .attachment-wrapper.animated {
550
+ animation: messageAppear var(--animation-time) ease-out forwards;
551
+ opacity: 0;
552
+ }
553
+ .attachment {
554
+ border-radius: 12px;
555
+ overflow: hidden;
556
+ max-width: 100%;
557
+ }
558
+ .attachment img {
559
+ max-width: 100%;
560
+ display: block;
561
+ border-radius: 12px;
562
+ }
563
+ .attachment video {
564
+ max-width: 100%;
565
+ display: block;
566
+ border-radius: 12px;
567
+ }
568
+ .attachment-audio {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 8px;
572
+ padding: 6px;
573
+ background: white;
574
+ border: 1px solid #e5e5ea;
575
+ border-radius: 12px;
576
+ min-width: 160px;
577
+ }
578
+ .attachment-wrapper.outgoing .attachment-audio {
579
+ background: white;
580
+ border: none;
581
+ }
582
+ .attachment-audio audio {
583
+ flex: 1;
584
+ max-height: 30px;
585
+ }
586
+ .attachment-location {
587
+ border-radius: 12px;
588
+ overflow: hidden;
589
+ }
590
+ .event-info {
591
+ text-align: center;
592
+ font-size: 11px;
593
+ color: #8e8e93;
594
+ margin: 4px 0;
595
+ padding: 0 10px;
596
+ line-height: 1.3;
597
+ }
598
+ .event-info.animated {
599
+ animation: messageAppear var(--animation-time) ease-out forwards;
600
+ opacity: 0;
601
+ }
602
+ .message-input {
603
+ background: linear-gradient(
604
+ to top,
605
+ rgba(0, 0, 0, 0.1) 0%,
606
+ rgba(0, 0, 0, 0.05) 70%,
607
+ transparent 100%
608
+ );
609
+ padding: 8px 12px;
610
+ border-top: none;
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 8px;
614
+ position: absolute;
615
+ bottom: 0px;
616
+ left: 0px;
617
+ right: 0px;
618
+ z-index: 10;
619
+ }
620
+ .message-input input {
621
+ flex: 1;
622
+ border: 1px solid #c6c6c8;
623
+ border-radius: 20px;
624
+ padding: 8px 15px;
625
+ font-size: 15px;
626
+ margin-bottom: 5px;
627
+ background: white;
628
+ border: none;
629
+ outline: none;
630
+ }
631
+ .message-input input::placeholder {
632
+ color: #8e8e93;
633
+ }
634
+ .attachment-button {
635
+ width: 30px;
636
+ height: 30px;
637
+ border-radius: 50%;
638
+ background: #fff;
639
+ border: none;
640
+ display: flex;
641
+ align-items: center;
642
+ justify-content: center;
643
+ cursor: pointer;
644
+ flex-shrink: 0;
645
+ margin-bottom: 5px;
646
+ transition: all var(--animation-time) ease;
647
+ color: #000;
648
+ }
649
+ .attachment-button:hover {
650
+ background: #f8f8f8ff;
651
+ transform: scale(1.05);
652
+ }
653
+ .attachment-button:active {
654
+ transform: scale(0.95);
655
+ }
656
+ .attachment-menu {
657
+ position: absolute;
658
+ bottom: 55px;
659
+ left: 12px;
660
+ background: white;
661
+ border-radius: 12px;
662
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
663
+ padding: 8px;
664
+ display: flex;
665
+ flex-direction: column;
666
+ gap: 4px;
667
+ opacity: 0;
668
+ pointer-events: none;
669
+ transform: translateY(10px);
670
+ transition: opacity var(--animation-time) ease, transform 0.2s ease;
671
+ z-index: 20;
672
+ }
673
+ .attachment-menu.open {
674
+ opacity: 1;
675
+ pointer-events: all;
676
+ transform: translateY(0);
677
+ }
678
+ .attachment-menu-item {
679
+ display: flex;
680
+ align-items: center;
681
+ gap: 8px;
682
+ padding: 8px 12px;
683
+ border-radius: 8px;
684
+ cursor: pointer;
685
+ transition: background var(--animation-time) ease;
686
+ white-space: nowrap;
687
+ font-size: 14px;
688
+ color: #1f2937;
689
+ }
690
+ .attachment-menu-item:hover {
691
+ background: #f3f4f6;
692
+ }
693
+ .attachment-menu-item temba-icon {
694
+ color: #007aff;
695
+ }
696
+ .quick-replies {
697
+ display: flex;
698
+ flex-wrap: wrap;
699
+ justify-content: center;
700
+ gap: 8px;
701
+ margin-top: 4px;
702
+ margin-bottom: 8px;
703
+ }
704
+ .quick-reply-btn {
705
+ background: white;
706
+ color: #007aff;
707
+ border: 1px solid #007aff;
708
+ border-radius: 18px;
709
+ padding: 4px 8px;
710
+ font-size: 11px;
711
+ cursor: pointer;
712
+ transition: all var(--animation-time) ease;
713
+ white-space: nowrap;
714
+ }
715
+ .quick-reply-btn:hover {
716
+ background: #007aff;
717
+ color: white;
718
+ cursor: pointer;
719
+ }
720
+ .quick-reply-btn:active {
721
+ transform: scale(0.95);
722
+ }
723
+ .quick-reply-btn.animated {
724
+ animation: messageAppear var(--animation-time) ease-out forwards;
725
+ opacity: 0;
726
+ }
727
+ `;
728
+ }
729
+ // method to reset attachment indices for testing
730
+ resetAttachmentIndices() {
731
+ this.imageIndex = 2;
732
+ this.videoIndex = 0;
733
+ this.audioIndex = 0;
734
+ this.locationIndex = 0;
735
+ }
736
+ get sizeConfig() {
737
+ return SIMULATOR_SIZES[this.size] || SIMULATOR_SIZES.medium;
738
+ }
739
+ get windowWidth() {
740
+ const config = this.sizeConfig;
741
+ return (config.contextWidth +
742
+ config.phoneWidth +
743
+ config.optionPaneWidth +
744
+ config.optionPaneGap +
745
+ config.contextOffset);
746
+ }
747
+ get leftBoundaryMargin() {
748
+ const config = this.sizeConfig;
749
+ return config.contextWidth + config.contextOffset;
750
+ }
751
+ get contextClosedLeft() {
752
+ const config = this.sizeConfig;
753
+ return config.contextWidth + config.contextOffset - config.phoneWidth;
754
+ }
755
+ updated(changes) {
756
+ super.updated(changes);
757
+ if (changes.has('flow') && this.flow) {
758
+ this.endpoint = `/flow/simulate/${this.flow}/`;
759
+ }
760
+ // handle attachment menu click outside listener
761
+ if (changes.has('attachmentMenuOpen')) {
762
+ if (this.attachmentMenuOpen) {
763
+ // create bound handler if it doesn't exist
764
+ if (!this.boundClickOutsideHandler) {
765
+ this.boundClickOutsideHandler =
766
+ this.handleClickOutsideAttachmentMenu.bind(this);
767
+ }
768
+ // add listener when menu opens
769
+ setTimeout(() => {
770
+ document.addEventListener('click', this.boundClickOutsideHandler);
771
+ }, 0);
772
+ }
773
+ else {
774
+ // remove listener when menu closes
775
+ if (this.boundClickOutsideHandler) {
776
+ document.removeEventListener('click', this.boundClickOutsideHandler);
777
+ }
778
+ }
779
+ }
780
+ // update floating window boundaries when size changes
781
+ if (changes.has('size')) {
782
+ requestAnimationFrame(() => {
783
+ var _a, _b;
784
+ const phoneWindow = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.getElementById('phone-window');
785
+ if (phoneWindow) {
786
+ // use the stored previous width since phoneWindow.width has already been updated
787
+ const oldWidth = this.previousWindowWidth || phoneWindow.width;
788
+ const oldRight = phoneWindow.left + oldWidth;
789
+ const config = this.sizeConfig;
790
+ const newWidth = this.windowWidth;
791
+ // store current width for next size change
792
+ this.previousWindowWidth = newWidth;
793
+ // update dimensions and boundaries
794
+ phoneWindow.width = newWidth;
795
+ phoneWindow.leftBoundaryMargin = this.leftBoundaryMargin;
796
+ phoneWindow.topBoundaryMargin = config.windowPadding;
797
+ phoneWindow.bottomBoundaryMargin = config.windowPadding;
798
+ // keep right edge in same position by adjusting left
799
+ let newLeft = oldRight - newWidth;
800
+ // apply same boundary logic as FloatingWindow.handleMouseMove
801
+ const padding = 20;
802
+ const minLeft = padding - this.leftBoundaryMargin;
803
+ const maxLeft = window.innerWidth -
804
+ newWidth -
805
+ padding +
806
+ phoneWindow.rightBoundaryMargin;
807
+ // clamp to boundaries
808
+ newLeft = Math.max(minLeft, Math.min(newLeft, maxLeft));
809
+ phoneWindow.left = newLeft;
810
+ // adjust vertical position if needed
811
+ const windowElement = (_b = phoneWindow.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('.window');
812
+ const currentHeight = (windowElement === null || windowElement === void 0 ? void 0 : windowElement.offsetHeight) || config.phoneTotalHeight;
813
+ const maxTop = Math.max(padding - config.windowPadding, window.innerHeight - currentHeight - padding + config.windowPadding);
814
+ phoneWindow.top = Math.max(padding - config.windowPadding, Math.min(phoneWindow.top, maxTop));
815
+ }
816
+ });
817
+ }
818
+ else {
819
+ // store initial width when first rendered
820
+ if (!this.previousWindowWidth) {
821
+ this.previousWindowWidth = this.windowWidth;
822
+ }
823
+ }
824
+ }
825
+ disconnectedCallback() {
826
+ super.disconnectedCallback();
827
+ // clean up event listener when component is removed
828
+ if (this.boundClickOutsideHandler) {
829
+ document.removeEventListener('click', this.boundClickOutsideHandler);
830
+ }
831
+ }
832
+ handleShow() {
833
+ const phoneWindow = this.shadowRoot.getElementById('phone-window');
834
+ phoneWindow.show();
835
+ this.isVisible = true;
836
+ getStore().getState().setSimulatorActive(true);
837
+ // start the simulation if we haven't already
838
+ if (this.events.length === 0) {
839
+ this.startFlow();
840
+ }
841
+ }
842
+ async startFlow() {
843
+ const now = new Date().toISOString();
844
+ // set created_on to simulation start time
845
+ this.contact = { ...this.contact, created_on: now };
846
+ const body = {
847
+ contact: this.contact,
848
+ trigger: {
849
+ type: 'manual',
850
+ triggered_on: now,
851
+ flow: { uuid: this.flow, name: 'New Chat' },
852
+ params: {}
853
+ }
854
+ };
855
+ try {
856
+ const response = await postJSON(this.endpoint, body);
857
+ this.updateRunContext(response.json);
858
+ }
859
+ catch (error) {
860
+ console.error('Failed to start simulation:', error);
861
+ this.events = [
862
+ ...this.events,
863
+ {
864
+ type: 'error',
865
+ created_on: now,
866
+ text: 'Failed to start simulation'
867
+ }
868
+ ];
869
+ }
870
+ }
871
+ updateRunContext(runContext, msgInEvt) {
872
+ var _a;
873
+ if (msgInEvt) {
874
+ this.events = [...this.events, msgInEvt];
875
+ }
876
+ if (runContext.session) {
877
+ this.session = runContext.session;
878
+ // update our contact with the latest from the session
879
+ if (runContext.contact) {
880
+ this.contact = runContext.contact;
881
+ }
882
+ }
883
+ // store the context from the response
884
+ if (runContext.context) {
885
+ this.context = runContext.context;
886
+ }
887
+ if (runContext.events && runContext.events.length > 0) {
888
+ this.events = [...this.events, ...runContext.events];
889
+ // extract quick replies from the most recent sprint
890
+ this.currentQuickReplies = [];
891
+ for (const event of runContext.events) {
892
+ if (event.type === 'msg_created' && ((_a = event.msg) === null || _a === void 0 ? void 0 : _a.quick_replies)) {
893
+ this.currentQuickReplies = event.msg.quick_replies;
894
+ }
895
+ }
896
+ }
897
+ this.sprinting = false;
898
+ this.requestUpdate();
899
+ this.scrollToBottom();
900
+ this.updateActivity();
901
+ }
902
+ updateActivity() {
903
+ if (!this.session) {
904
+ return;
905
+ }
906
+ const pathCounts = {};
907
+ const nodeCounts = {};
908
+ // iterate through all runs to get path segment counts
909
+ for (const run of this.session.runs) {
910
+ if (run.path) {
911
+ for (let i = 0; i < run.path.length - 1; i++) {
912
+ const step = run.path[i];
913
+ const nextStep = run.path[i + 1];
914
+ if (step.exit_uuid && nextStep.node_uuid) {
915
+ const key = step.exit_uuid + ':' + nextStep.node_uuid;
916
+ pathCounts[key] = (pathCounts[key] || 0) + 1;
917
+ }
918
+ }
919
+ }
920
+ // set node counts on the last step of any active/waiting runs
921
+ if (run.status === 'active' || run.status === 'waiting') {
922
+ if (run.path && run.path.length > 0) {
923
+ const finalStep = run.path[run.path.length - 1];
924
+ if (finalStep && finalStep.node_uuid) {
925
+ nodeCounts[finalStep.node_uuid] =
926
+ (nodeCounts[finalStep.node_uuid] || 0) + 1;
927
+ }
928
+ }
929
+ }
930
+ }
931
+ // Update simulator activity in the store
932
+ getStore().getState().updateSimulatorActivity({
933
+ segments: pathCounts,
934
+ nodes: nodeCounts
935
+ });
936
+ // Fire follow event if following is enabled
937
+ if (this.following) {
938
+ this.fireFollowEvent();
939
+ }
940
+ }
941
+ fireFollowEvent() {
942
+ var _a;
943
+ if (!this.session || !this.session.runs || this.session.runs.length === 0) {
944
+ return;
945
+ }
946
+ // Find the first active or waiting run
947
+ let activeRun = this.session.runs.find((run) => run.status === 'active' || run.status === 'waiting');
948
+ // If no active/waiting run and simulation has ended, use the first completed run
949
+ if (!activeRun) {
950
+ activeRun = this.session.runs.find((run) => run.status === 'completed');
951
+ }
952
+ if (activeRun && activeRun.path && activeRun.path.length > 0) {
953
+ const finalStep = activeRun.path[activeRun.path.length - 1];
954
+ if (finalStep && finalStep.node_uuid) {
955
+ this.fireCustomEvent(CustomEventType.FollowSimulation, {
956
+ flowUuid: ((_a = activeRun.flow) === null || _a === void 0 ? void 0 : _a.uuid) || this.flow,
957
+ nodeUuid: finalStep.node_uuid
958
+ });
959
+ }
960
+ }
961
+ }
962
+ scrollToBottom() {
963
+ // wait for render, then scroll to bottom
964
+ setTimeout(() => {
965
+ var _a, _b;
966
+ const screen = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.phone-screen');
967
+ if (screen) {
968
+ screen.scrollTop = screen.scrollHeight;
969
+ }
970
+ // update previous count after animation completes
971
+ this.previousEventCount = this.events.length;
972
+ // return focus to input
973
+ const input = (_b = this.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('.message-input input');
974
+ if (input) {
975
+ input.focus();
976
+ }
977
+ }, 50);
978
+ }
979
+ handleClose() {
980
+ const phoneWindow = this.shadowRoot.getElementById('phone-window');
981
+ phoneWindow.hide();
982
+ this.isVisible = false;
983
+ getStore().getState().setSimulatorActive(false);
984
+ const phoneTab = this.shadowRoot.getElementById('phone-tab');
985
+ phoneTab.hidden = false;
986
+ }
987
+ handleReset() {
988
+ // reset simulation state
989
+ this.events = [];
990
+ this.session = null;
991
+ this.context = null;
992
+ this.inputValue = '';
993
+ this.sprinting = false;
994
+ this.previousEventCount = 0;
995
+ this.currentQuickReplies = [];
996
+ // Clear simulator activity data
997
+ getStore().getState().updateSimulatorActivity({
998
+ segments: {},
999
+ nodes: {}
1000
+ });
1001
+ // reset contact to initial state
1002
+ this.contact = {
1003
+ uuid: 'fb3787ab-2eda-48a0-a2bc-e2ddadec1286',
1004
+ urns: ['tel:+12065551212'],
1005
+ fields: {},
1006
+ groups: [],
1007
+ language: 'eng',
1008
+ status: 'active',
1009
+ created_on: new Date().toISOString()
1010
+ };
1011
+ // restart the flow
1012
+ this.startFlow();
1013
+ }
1014
+ handleToggleFollow() {
1015
+ this.following = !this.following;
1016
+ }
1017
+ handleCycleSize() {
1018
+ const sizes = [
1019
+ 'small',
1020
+ 'medium',
1021
+ 'large'
1022
+ ];
1023
+ const currentIndex = sizes.indexOf(this.size);
1024
+ const nextIndex = (currentIndex + 1) % sizes.length;
1025
+ this.size = sizes[nextIndex];
1026
+ }
1027
+ handleToggleContextExplorer() {
1028
+ this.contextExplorerOpen = !this.contextExplorerOpen;
1029
+ // if opening the context explorer, ensure it's not off-screen
1030
+ if (this.contextExplorerOpen) {
1031
+ requestAnimationFrame(() => {
1032
+ var _a;
1033
+ const phoneWindow = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.getElementById('phone-window');
1034
+ if (phoneWindow) {
1035
+ const padding = 20;
1036
+ const contextExplorerLeft = this.sizeConfig.contextOffset;
1037
+ const minWindowLeft = padding - contextExplorerLeft;
1038
+ if (phoneWindow.left < minWindowLeft) {
1039
+ phoneWindow.left = minWindowLeft;
1040
+ }
1041
+ }
1042
+ });
1043
+ }
1044
+ }
1045
+ togglePath(path) {
1046
+ if (this.expandedPaths.has(path)) {
1047
+ this.expandedPaths.delete(path);
1048
+ }
1049
+ else {
1050
+ this.expandedPaths.add(path);
1051
+ }
1052
+ this.requestUpdate();
1053
+ }
1054
+ isExpandable(value) {
1055
+ if (value === null || typeof value !== 'object') {
1056
+ return false;
1057
+ }
1058
+ if (Array.isArray(value)) {
1059
+ return value.length > 0;
1060
+ }
1061
+ // check if object has keys other than __default__
1062
+ const keys = Object.keys(value).filter((key) => key !== '__default__');
1063
+ return keys.length > 0;
1064
+ }
1065
+ renderContextValue(value) {
1066
+ if (value === null || value === undefined)
1067
+ return '';
1068
+ if (typeof value === 'boolean')
1069
+ return html `<span class="context-value">${value}</span>`;
1070
+ if (typeof value === 'number')
1071
+ return html `<span class="context-value">${value}</span>`;
1072
+ if (typeof value === 'string')
1073
+ return html `<span class="context-value">${value}</span>`;
1074
+ if (Array.isArray(value))
1075
+ return html `<span class="context-value">[${value.length}]</span>`;
1076
+ return '';
1077
+ }
1078
+ buildExpression(path) {
1079
+ return `@${path}`;
1080
+ }
1081
+ async handleCopyExpression(path, event) {
1082
+ event.stopPropagation();
1083
+ const expression = this.buildExpression(path);
1084
+ try {
1085
+ await navigator.clipboard.writeText(expression);
1086
+ this.copiedExpression = expression;
1087
+ // clear the toast after 2 seconds
1088
+ setTimeout(() => {
1089
+ this.copiedExpression = '';
1090
+ }, 2000);
1091
+ }
1092
+ catch (err) {
1093
+ console.error('Failed to copy expression:', err);
1094
+ }
1095
+ }
1096
+ handleToggleShowAllKeys() {
1097
+ this.showAllKeys = !this.showAllKeys;
1098
+ this.toastMessage = this.showAllKeys
1099
+ ? 'Showing all keys'
1100
+ : 'Filtering out keys without values';
1101
+ // clear the toast after 2 seconds
1102
+ setTimeout(() => {
1103
+ this.toastMessage = '';
1104
+ }, 2000);
1105
+ }
1106
+ renderContextTree(obj, path = '') {
1107
+ if (!obj || typeof obj !== 'object') {
1108
+ return html ``;
1109
+ }
1110
+ let entries = Array.isArray(obj)
1111
+ ? obj.map((v, i) => [String(i), v])
1112
+ : Object.entries(obj).filter(([key]) => key !== '__default__');
1113
+ // filter out keys without values if showAllKeys is false
1114
+ if (!this.showAllKeys) {
1115
+ entries = entries.filter(([, value]) => {
1116
+ // keep if expandable (has children)
1117
+ if (this.isExpandable(value))
1118
+ return true;
1119
+ // keep if it has a displayable value (not null/undefined)
1120
+ if (value === null || value === undefined)
1121
+ return false;
1122
+ // keep primitives with values
1123
+ return (typeof value === 'boolean' ||
1124
+ typeof value === 'number' ||
1125
+ typeof value === 'string' ||
1126
+ Array.isArray(value));
1127
+ });
1128
+ }
1129
+ return html `${entries.map(([key, value]) => {
1130
+ const currentPath = path ? `${path}.${key}` : key;
1131
+ const isExpanded = this.expandedPaths.has(currentPath);
1132
+ const expandable = this.isExpandable(value);
1133
+ // check if this object has a __default__ value
1134
+ let displayValue = value;
1135
+ if (expandable &&
1136
+ !Array.isArray(value) &&
1137
+ value !== null &&
1138
+ typeof value === 'object' &&
1139
+ '__default__' in value) {
1140
+ displayValue = value.__default__;
1141
+ }
1142
+ return html `
1143
+ <div>
1144
+ <div
1145
+ class="context-item ${expandable ? 'context-item-expandable' : ''}"
1146
+ @click=${() => expandable && this.togglePath(currentPath)}
1147
+ >
1148
+ ${expandable
1149
+ ? html `<span
1150
+ class="context-expand-icon ${isExpanded ? 'expanded' : ''}"
1151
+ >›</span
1152
+ >`
1153
+ : html `<span class="context-expand-icon"></span>`}
1154
+ <span class="context-key ${expandable ? 'has-value' : ''}"
1155
+ >${key}
1156
+ <temba-icon
1157
+ class="context-copy-icon"
1158
+ name="copy"
1159
+ size="0.9"
1160
+ @click=${(e) => this.handleCopyExpression(currentPath, e)}
1161
+ ></temba-icon>
1162
+ </span>
1163
+ ${!isExpanded ? this.renderContextValue(displayValue) : html ``}
1164
+ </div>
1165
+ ${isExpanded
1166
+ ? html `<div class="context-children">
1167
+ ${this.renderContextTree(value, currentPath)}
1168
+ </div>`
1169
+ : html ``}
1170
+ </div>
1171
+ `;
1172
+ })}`;
1173
+ }
1174
+ async resume(text, attachment) {
1175
+ if ((!text && !attachment) || !this.session) {
1176
+ return;
1177
+ }
1178
+ this.sprinting = true;
1179
+ this.inputValue = '';
1180
+ this.currentQuickReplies = [];
1181
+ this.attachmentMenuOpen = false;
1182
+ const now = new Date().toISOString();
1183
+ const msgInEvt = {
1184
+ uuid: crypto.randomUUID(),
1185
+ type: 'msg_received',
1186
+ created_on: now,
1187
+ msg: {
1188
+ uuid: crypto.randomUUID(),
1189
+ text: text || '',
1190
+ urn: this.contact.urns[0],
1191
+ attachments: attachment ? [attachment] : []
1192
+ }
1193
+ };
1194
+ // show user's message immediately
1195
+ this.events = [...this.events, msgInEvt];
1196
+ this.requestUpdate();
1197
+ this.scrollToBottom();
1198
+ const body = {
1199
+ session: this.session,
1200
+ contact: this.contact,
1201
+ resume: {
1202
+ type: 'msg',
1203
+ event: msgInEvt,
1204
+ resumed_on: now
1205
+ }
1206
+ };
1207
+ try {
1208
+ const response = await postJSON(this.endpoint, body);
1209
+ // add a small delay before showing the reply to simulate typing
1210
+ await new Promise((resolve) => setTimeout(resolve, 400));
1211
+ // pass null for msgInEvt since we already added it
1212
+ this.updateRunContext(response.json, null);
1213
+ }
1214
+ catch (error) {
1215
+ console.error('Failed to resume simulation:', error);
1216
+ this.events = [
1217
+ ...this.events,
1218
+ {
1219
+ type: 'error',
1220
+ created_on: now,
1221
+ text: 'Failed to send message'
1222
+ }
1223
+ ];
1224
+ this.sprinting = false;
1225
+ }
1226
+ }
1227
+ handleKeyUp(evt) {
1228
+ if (evt.key === 'Enter') {
1229
+ const input = evt.target;
1230
+ const text = input.value.trim();
1231
+ if (text) {
1232
+ this.resume(text);
1233
+ }
1234
+ }
1235
+ }
1236
+ handleInput(evt) {
1237
+ const input = evt.target;
1238
+ this.inputValue = input.value;
1239
+ }
1240
+ handleQuickReply(quickReply) {
1241
+ if (!this.sprinting) {
1242
+ this.resume(quickReply);
1243
+ }
1244
+ }
1245
+ handleToggleAttachmentMenu() {
1246
+ this.attachmentMenuOpen = !this.attachmentMenuOpen;
1247
+ }
1248
+ handleClickOutsideAttachmentMenu(event) {
1249
+ var _a, _b;
1250
+ if (!this.attachmentMenuOpen) {
1251
+ return;
1252
+ }
1253
+ const menu = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.attachment-menu');
1254
+ const button = (_b = this.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('.attachment-button');
1255
+ if (!menu || !button) {
1256
+ return;
1257
+ }
1258
+ // check if click is outside both menu and button
1259
+ const clickedInsideMenu = menu.contains(event.target);
1260
+ const clickedInsideButton = button.contains(event.target);
1261
+ if (!clickedInsideMenu && !clickedInsideButton) {
1262
+ this.attachmentMenuOpen = false;
1263
+ }
1264
+ }
1265
+ handleSendAttachment(attachmentType) {
1266
+ let attachment = '';
1267
+ switch (attachmentType) {
1268
+ case 'image':
1269
+ attachment = `image/jpeg:${TEST_IMAGES[this.imageIndex]}`;
1270
+ this.imageIndex = (this.imageIndex + 1) % TEST_IMAGES.length;
1271
+ break;
1272
+ case 'video':
1273
+ attachment = `video/mp4:${TEST_VIDEOS[this.videoIndex]}`;
1274
+ this.videoIndex = (this.videoIndex + 1) % TEST_VIDEOS.length;
1275
+ break;
1276
+ case 'audio':
1277
+ attachment = `audio/mp3:${TEST_AUDIO[this.audioIndex]}`;
1278
+ this.audioIndex = (this.audioIndex + 1) % TEST_AUDIO.length;
1279
+ break;
1280
+ case 'location':
1281
+ attachment = TEST_LOCATIONS[this.locationIndex];
1282
+ this.locationIndex = (this.locationIndex + 1) % TEST_LOCATIONS.length;
1283
+ break;
1284
+ }
1285
+ if (attachment) {
1286
+ this.resume('', attachment);
1287
+ }
1288
+ }
1289
+ getEventDescription(event) {
1290
+ switch (event.type) {
1291
+ case 'contact_groups_changed': {
1292
+ const groups = event.groups_added || [];
1293
+ const removedGroups = event.groups_removed || [];
1294
+ if (groups.length > 0) {
1295
+ const groupNames = groups.map((g) => `"${g.name}"`).join(', ');
1296
+ return `Added to ${groupNames}`;
1297
+ }
1298
+ if (removedGroups.length > 0) {
1299
+ const groupNames = removedGroups
1300
+ .map((g) => `"${g.name}"`)
1301
+ .join(', ');
1302
+ return `Removed from ${groupNames}`;
1303
+ }
1304
+ break;
1305
+ }
1306
+ case 'contact_field_changed': {
1307
+ const field = event.field;
1308
+ const value = event.value;
1309
+ const valueText = value ? value.text || value : '';
1310
+ if (field) {
1311
+ if (valueText) {
1312
+ return `Set contact "${field.name}" to "${valueText}"`;
1313
+ }
1314
+ else {
1315
+ return `Cleared contact "${field.name}"`;
1316
+ }
1317
+ }
1318
+ break;
1319
+ }
1320
+ case 'contact_language_changed':
1321
+ return `Set preferred language to "${event.language}"`;
1322
+ case 'contact_name_changed':
1323
+ return `Set contact name to "${event.name}"`;
1324
+ case 'contact_status_changed':
1325
+ return `Set status to "${event.status}"`;
1326
+ case 'contact_urns_changed':
1327
+ return `Added a URN for the contact`;
1328
+ case 'input_labels_added': {
1329
+ const labels = event.labels || [];
1330
+ if (labels.length > 0) {
1331
+ const labelNames = labels.map((l) => `"${l.name}"`).join(', ');
1332
+ return `Message labeled with ${labelNames}`;
1333
+ }
1334
+ break;
1335
+ }
1336
+ case 'run_result_changed':
1337
+ return `Set result "${event.name}" to "${event.value}"`;
1338
+ case 'run_started':
1339
+ case 'flow_entered': {
1340
+ const flow = event.flow;
1341
+ if (flow) {
1342
+ return `Entered flow "${flow.name}"`;
1343
+ }
1344
+ break;
1345
+ }
1346
+ case 'run_ended': {
1347
+ const flow = event.flow;
1348
+ if (flow) {
1349
+ return `Exited flow "${flow.name}"`;
1350
+ }
1351
+ break;
1352
+ }
1353
+ case 'email_created':
1354
+ case 'email_sent': {
1355
+ const recipients = event.to || event.addresses || [];
1356
+ const subject = event.subject;
1357
+ const recipientList = recipients
1358
+ .map((r) => `"${r}"`)
1359
+ .join(', ');
1360
+ return `Sent email to ${recipientList} with subject "${subject}"`;
1361
+ }
1362
+ case 'broadcast_created': {
1363
+ const translations = event.translations;
1364
+ const baseLanguage = event.base_language;
1365
+ if (translations && translations[baseLanguage]) {
1366
+ return `Sent broadcast: "${translations[baseLanguage].text}"`;
1367
+ }
1368
+ return `Sent broadcast`;
1369
+ }
1370
+ case 'session_triggered': {
1371
+ const flow = event.flow;
1372
+ if (flow) {
1373
+ return `Started somebody else in "${flow.name}"`;
1374
+ }
1375
+ break;
1376
+ }
1377
+ case 'ticket_opened': {
1378
+ const ticket = event.ticket;
1379
+ if (ticket && ticket.topic) {
1380
+ return `Ticket opened with topic "${ticket.topic.name}"`;
1381
+ }
1382
+ return `Ticket opened`;
1383
+ }
1384
+ case 'resthook_called':
1385
+ return `Triggered flow event "${event.resthook}"`;
1386
+ case 'webhook_called':
1387
+ return `Called ${event.url}`;
1388
+ case 'service_called': {
1389
+ const service = event.service;
1390
+ if (service === 'classifier') {
1391
+ return `Called classifier`;
1392
+ }
1393
+ return `Called ${service}`;
1394
+ }
1395
+ case 'airtime_transferred': {
1396
+ const amount = event.actual_amount;
1397
+ const currency = event.currency;
1398
+ const recipient = event.recipient;
1399
+ if (amount && currency && recipient) {
1400
+ return `Transferred ${amount} ${currency} to ${recipient}`;
1401
+ }
1402
+ break;
1403
+ }
1404
+ case 'info':
1405
+ return event.text;
1406
+ case 'warning':
1407
+ return `⚠️ ${event.text}`;
1408
+ }
1409
+ return null;
1410
+ }
1411
+ renderAttachment(attachment) {
1412
+ // parse attachment format: "type/subtype:url" or "geo:lat,long"
1413
+ const parts = attachment.split(':');
1414
+ const type = parts[0];
1415
+ const content = parts.slice(1).join(':'); // rejoin in case url has colons
1416
+ if (type === 'geo') {
1417
+ // use temba-thumbnail for location to get map image
1418
+ return html `
1419
+ <div class="attachment-location">
1420
+ <temba-thumbnail attachment="${attachment}"></temba-thumbnail>
1421
+ </div>
1422
+ `;
1423
+ }
1424
+ else if (type.startsWith('image/')) {
1425
+ // custom image rendering
1426
+ return html `
1427
+ <div class="attachment">
1428
+ <img src="${content}" alt="Image attachment" />
1429
+ </div>
1430
+ `;
1431
+ }
1432
+ else if (type.startsWith('video/')) {
1433
+ // custom video rendering
1434
+ return html `
1435
+ <div class="attachment">
1436
+ <video controls>
1437
+ <source src="${content}" type="${type}" />
1438
+ </video>
1439
+ </div>
1440
+ `;
1441
+ }
1442
+ else if (type.startsWith('audio/')) {
1443
+ // custom audio rendering
1444
+ return html `
1445
+ <div class="attachment">
1446
+ <div class="attachment-audio">
1447
+ <audio controls>
1448
+ <source src="${content}" type="${type}" />
1449
+ </audio>
1450
+ </div>
1451
+ </div>
1452
+ `;
1453
+ }
1454
+ // fallback for unknown types
1455
+ return html `
1456
+ <div class="attachment">
1457
+ <span>Attachment</span>
1458
+ </div>
1459
+ `;
1460
+ }
1461
+ renderMessages() {
1462
+ if (this.events.length === 0) {
1463
+ return html `
1464
+ <div class="message incoming">👋 Welcome! Starting simulation...</div>
1465
+ `;
1466
+ }
1467
+ const eventTemplates = this.events.map((event, index) => {
1468
+ // only animate messages that are new (beyond previous count)
1469
+ const isNew = index >= this.previousEventCount;
1470
+ const animatedClass = isNew ? 'animated' : '';
1471
+ // stagger animations for new messages
1472
+ const animationDelay = isNew
1473
+ ? `${(index - this.previousEventCount) * 0.2}s`
1474
+ : '0s';
1475
+ if (event.type === 'msg_received' && event.msg) {
1476
+ const hasAttachments = event.msg.attachments && event.msg.attachments.length > 0;
1477
+ const hasText = event.msg.text && event.msg.text.trim().length > 0;
1478
+ return html `
1479
+ ${hasAttachments
1480
+ ? html `
1481
+ <div
1482
+ class="attachment-wrapper outgoing ${animatedClass}"
1483
+ style="animation-delay: ${animationDelay}"
1484
+ >
1485
+ ${event.msg.attachments.map((att) => this.renderAttachment(att))}
1486
+ </div>
1487
+ `
1488
+ : html ``}
1489
+ ${hasText
1490
+ ? html `
1491
+ <div
1492
+ class="message outgoing ${animatedClass}"
1493
+ style="animation-delay: ${animationDelay}"
1494
+ >
1495
+ ${event.msg.text}
1496
+ </div>
1497
+ `
1498
+ : html ``}
1499
+ `;
1500
+ }
1501
+ else if (event.type === 'msg_created' && event.msg) {
1502
+ const hasAttachments = event.msg.attachments && event.msg.attachments.length > 0;
1503
+ const hasText = event.msg.text && event.msg.text.trim().length > 0;
1504
+ return html `
1505
+ ${hasAttachments
1506
+ ? html `
1507
+ <div
1508
+ class="attachment-wrapper incoming ${animatedClass}"
1509
+ style="animation-delay: ${animationDelay}"
1510
+ >
1511
+ ${event.msg.attachments.map((att) => this.renderAttachment(att))}
1512
+ </div>
1513
+ `
1514
+ : html ``}
1515
+ ${hasText
1516
+ ? html `
1517
+ <div
1518
+ class="message incoming ${animatedClass}"
1519
+ style="animation-delay: ${animationDelay}"
1520
+ >
1521
+ ${event.msg.text}
1522
+ </div>
1523
+ `
1524
+ : html ``}
1525
+ `;
1526
+ }
1527
+ else if (event.type === 'error') {
1528
+ return html `
1529
+ <div
1530
+ class="message incoming ${animatedClass}"
1531
+ style="background: #ff4444; color: white; animation-delay: ${animationDelay}"
1532
+ >
1533
+ ⚠️ ${event.text || 'An error occurred'}
1534
+ </div>
1535
+ `;
1536
+ }
1537
+ else {
1538
+ // check if this is an event we should display
1539
+ const description = this.getEventDescription(event);
1540
+ if (description) {
1541
+ return html `
1542
+ <div
1543
+ class="event-info ${animatedClass}"
1544
+ style="animation-delay: ${animationDelay}"
1545
+ >
1546
+ ${description}
1547
+ </div>
1548
+ `;
1549
+ }
1550
+ }
1551
+ return html ``;
1552
+ });
1553
+ // render quick replies at the end if we have any from the most recent sprint
1554
+ const hasQuickReplies = this.currentQuickReplies.length > 0;
1555
+ const quickRepliesAnimationDelay = this.events.length >= this.previousEventCount
1556
+ ? `${(this.events.length - this.previousEventCount) * 0.2}s`
1557
+ : '0s';
1558
+ return html `
1559
+ ${eventTemplates}
1560
+ ${hasQuickReplies
1561
+ ? html `
1562
+ <div
1563
+ class="quick-replies animated"
1564
+ style="animation-delay: ${quickRepliesAnimationDelay}"
1565
+ >
1566
+ ${this.currentQuickReplies.map((qr) => html `
1567
+ <button
1568
+ class="quick-reply-btn animated"
1569
+ style="animation-delay: ${quickRepliesAnimationDelay}"
1570
+ @click=${() => this.handleQuickReply(qr.text)}
1571
+ >
1572
+ ${qr.text}
1573
+ </button>
1574
+ `)}
1575
+ </div>
1576
+ `
1577
+ : html ``}
1578
+ `;
1579
+ }
1580
+ render() {
1581
+ const config = this.sizeConfig;
1582
+ // set CSS custom properties dynamically based on size
1583
+ const styleVars = `
1584
+ --phone-width: ${config.phoneWidth}px;
1585
+ --phone-total-height: ${config.phoneTotalHeight}px;
1586
+ --context-width: ${config.contextWidth}px;
1587
+ --context-offset: ${config.contextOffset}px;
1588
+ --option-pane-width: ${config.optionPaneWidth}px;
1589
+ --option-pane-gap: ${config.optionPaneGap}px;
1590
+ --window-padding: ${config.windowPadding}px;
1591
+ --phone-screen-height: ${config.phoneScreenHeight}px;
1592
+ --context-height: ${config.contextHeight}px;
1593
+ --context-closed-left: ${this.contextClosedLeft}px;
1594
+ --cutout-height: ${config.cutoutHeight}px;
1595
+ --cutout-padding: ${config.cutoutPadding}px;
1596
+ --cutout-font-size: ${config.cutoutFontSize}px;
1597
+ --cutout-island-width: ${config.cutoutIslandWidth}px;
1598
+ --cutout-island-height: ${config.cutoutIslandHeight}px;
1599
+ --cutout-island-top: ${config.cutoutIslandTop}px;
1600
+ `;
1601
+ return html `
1602
+ <temba-floating-window
1603
+ id="phone-window"
1604
+ width="${this.windowWidth}"
1605
+ leftBoundaryMargin="${this.leftBoundaryMargin}"
1606
+ bottomBoundaryMargin="${config.windowPadding}"
1607
+ topBoundaryMargin="${config.windowPadding}"
1608
+ height="${config.phoneTotalHeight}"
1609
+ top="60"
1610
+ chromeless
1611
+ >
1612
+ <div class="phone-simulator" style="${styleVars}">
1613
+ <div
1614
+ class="context-explorer ${this.contextExplorerOpen ? 'open' : ''}"
1615
+ >
1616
+ <div class="context-explorer-scroll">
1617
+ ${this.context
1618
+ ? this.renderContextTree(this.context)
1619
+ : html `<div
1620
+ style="color: #9ca3af; padding: 8px; text-align: center;"
1621
+ >
1622
+ No context available
1623
+ </div>`}
1624
+ </div>
1625
+ <div class="context-gutter">
1626
+ <div
1627
+ class="context-gutter-btn ${this.showAllKeys ? '' : 'active'}"
1628
+ @click=${this.handleToggleShowAllKeys}
1629
+ title="${this.showAllKeys
1630
+ ? 'Show keys with values only'
1631
+ : 'Show all keys'}"
1632
+ >
1633
+ <temba-icon
1634
+ name="${this.showAllKeys ? 'filter' : 'filter'}"
1635
+ size="1"
1636
+ ></temba-icon>
1637
+ </div>
1638
+ <div class="context-gutter-spacer"></div>
1639
+ <div
1640
+ class="context-gutter-btn"
1641
+ @click=${this.handleToggleContextExplorer}
1642
+ title="Close"
1643
+ >
1644
+ <temba-icon name="x" size="1"></temba-icon>
1645
+ </div>
1646
+ </div>
1647
+ ${this.copiedExpression
1648
+ ? html `<div class="context-toast">
1649
+ Copied
1650
+ <span class="expression">${this.copiedExpression}</span>
1651
+ to the clipboard
1652
+ </div>`
1653
+ : this.toastMessage
1654
+ ? html `<div class="context-toast">${this.toastMessage}</div>`
1655
+ : html ``}
1656
+ </div>
1657
+
1658
+ <div
1659
+ class="phone-frame"
1660
+ style="pointer-events: ${this.isVisible ? 'all' : 'none'}"
1661
+ >
1662
+ <div class="phone-top drag-handle">
1663
+ <div class="phone-notch">
1664
+ <div class="dynamic-island"></div>
1665
+ </div>
1666
+ </div>
1667
+ <div class="phone-screen">${this.renderMessages()}</div>
1668
+ <div class="message-input">
1669
+ <button
1670
+ class="attachment-button"
1671
+ @click=${this.handleToggleAttachmentMenu}
1672
+ ?disabled=${this.sprinting}
1673
+ >
1674
+ <temba-icon name="plus" size="1.5"></temba-icon>
1675
+ </button>
1676
+ <input
1677
+ type="text"
1678
+ placeholder="Enter Message"
1679
+ .value=${this.inputValue}
1680
+ @input=${this.handleInput}
1681
+ @keyup=${this.handleKeyUp}
1682
+ ?disabled=${this.sprinting}
1683
+ />
1684
+ <div
1685
+ class="attachment-menu ${this.attachmentMenuOpen ? 'open' : ''}"
1686
+ >
1687
+ <div
1688
+ class="attachment-menu-item"
1689
+ @click=${() => this.handleSendAttachment('image')}
1690
+ >
1691
+ <temba-icon name="attachment_image" size="1.2"></temba-icon>
1692
+ <span>Image</span>
1693
+ </div>
1694
+ <div
1695
+ class="attachment-menu-item"
1696
+ @click=${() => this.handleSendAttachment('video')}
1697
+ >
1698
+ <temba-icon name="attachment_video" size="1.2"></temba-icon>
1699
+ <span>Video</span>
1700
+ </div>
1701
+ <div
1702
+ class="attachment-menu-item"
1703
+ @click=${() => this.handleSendAttachment('audio')}
1704
+ >
1705
+ <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1706
+ <span>Audio</span>
1707
+ </div>
1708
+ <div
1709
+ class="attachment-menu-item"
1710
+ @click=${() => this.handleSendAttachment('location')}
1711
+ >
1712
+ <temba-icon
1713
+ name="attachment_location"
1714
+ size="1.2"
1715
+ ></temba-icon>
1716
+ <span>Location</span>
1717
+ </div>
1718
+ </div>
1719
+ </div>
1720
+ </div>
1721
+ <div class="option-pane">
1722
+ <button class="option-btn" @click=${this.handleClose} title="Close">
1723
+ <temba-icon name="x" size="1.5"></temba-icon>
1724
+ </button>
1725
+ <button
1726
+ class="option-btn ${this.following ? 'active' : ''}"
1727
+ @click=${this.handleToggleFollow}
1728
+ title="${this.following ? 'Following' : 'Follow'}"
1729
+ >
1730
+ <temba-icon name="follow" size="1.5"></temba-icon>
1731
+ </button>
1732
+
1733
+ <button
1734
+ class="option-btn ${this.contextExplorerOpen ? 'active' : ''}"
1735
+ @click=${this.handleToggleContextExplorer}
1736
+ title="Context Explorer"
1737
+ >
1738
+ <temba-icon name="expressions" size="1.5"></temba-icon>
1739
+ </button>
1740
+
1741
+ <button
1742
+ class="option-btn"
1743
+ @click=${this.handleCycleSize}
1744
+ title="Size: ${this.size}"
1745
+ >
1746
+ ${this.size === 'small'
1747
+ ? 'S'
1748
+ : this.size === 'medium'
1749
+ ? 'M'
1750
+ : 'L'}
1751
+ </button>
1752
+
1753
+ <button class="option-btn" @click=${this.handleReset} title="Reset">
1754
+ <temba-icon name="delete" size="1.5"></temba-icon>
1755
+ </button>
1756
+ </div>
1757
+ </div>
1758
+ </temba-floating-window>
1759
+
1760
+ <temba-floating-tab
1761
+ id="phone-tab"
1762
+ icon="simulator"
1763
+ label="Phone Simulator"
1764
+ color="#10b981"
1765
+ @temba-button-clicked=${this.handleShow}
1766
+ ></temba-floating-tab>
1767
+ `;
1768
+ }
1769
+ }
1770
+ __decorate([
1771
+ property({ type: String })
1772
+ ], Simulator.prototype, "flow", void 0);
1773
+ __decorate([
1774
+ property({ type: String })
1775
+ ], Simulator.prototype, "endpoint", void 0);
1776
+ __decorate([
1777
+ property({ type: Number })
1778
+ ], Simulator.prototype, "animationTime", void 0);
1779
+ __decorate([
1780
+ fromCookie('simulator-size', 'small')
1781
+ ], Simulator.prototype, "size", void 0);
1782
+ __decorate([
1783
+ property({ type: Array })
1784
+ ], Simulator.prototype, "events", void 0);
1785
+ __decorate([
1786
+ property({ type: Object })
1787
+ ], Simulator.prototype, "session", void 0);
1788
+ __decorate([
1789
+ property({ type: Object })
1790
+ ], Simulator.prototype, "context", void 0);
1791
+ __decorate([
1792
+ property({ type: Object })
1793
+ ], Simulator.prototype, "contact", void 0);
1794
+ __decorate([
1795
+ property({ type: Boolean })
1796
+ ], Simulator.prototype, "sprinting", void 0);
1797
+ __decorate([
1798
+ property({ type: String })
1799
+ ], Simulator.prototype, "inputValue", void 0);
1800
+ __decorate([
1801
+ fromCookie('simulator-follow', true)
1802
+ ], Simulator.prototype, "following", void 0);
1803
+ __decorate([
1804
+ fromCookie('simulator-context-open', false)
1805
+ ], Simulator.prototype, "contextExplorerOpen", void 0);
1806
+ __decorate([
1807
+ property({ type: Object })
1808
+ ], Simulator.prototype, "expandedPaths", void 0);
1809
+ __decorate([
1810
+ property({ type: String })
1811
+ ], Simulator.prototype, "copiedExpression", void 0);
1812
+ __decorate([
1813
+ property({ type: String })
1814
+ ], Simulator.prototype, "toastMessage", void 0);
1815
+ __decorate([
1816
+ property({ type: Boolean })
1817
+ ], Simulator.prototype, "showAllKeys", void 0);
1818
+ __decorate([
1819
+ property({ type: Array })
1820
+ ], Simulator.prototype, "currentQuickReplies", void 0);
1821
+ __decorate([
1822
+ property({ type: Boolean })
1823
+ ], Simulator.prototype, "isVisible", void 0);
1824
+ __decorate([
1825
+ property({ type: Boolean })
1826
+ ], Simulator.prototype, "attachmentMenuOpen", void 0);
1827
+ //# sourceMappingURL=Simulator.js.map