@nyaruka/temba-components 0.136.0 → 0.137.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 (137) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/demo/components/webchat/example.html +2 -2
  3. package/dist/temba-components.js +537 -578
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/display/Chat.js +123 -44
  6. package/out-tsc/src/display/Chat.js.map +1 -1
  7. package/out-tsc/src/display/FloatingTab.js +2 -6
  8. package/out-tsc/src/display/FloatingTab.js.map +1 -1
  9. package/out-tsc/src/events/eventRenderers.js +442 -0
  10. package/out-tsc/src/events/eventRenderers.js.map +1 -0
  11. package/out-tsc/src/flow/CanvasNode.js +18 -1
  12. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  13. package/out-tsc/src/flow/Editor.js +10 -7
  14. package/out-tsc/src/flow/Editor.js.map +1 -1
  15. package/out-tsc/src/flow/NodeEditor.js +0 -1
  16. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  17. package/out-tsc/src/layout/FloatingWindow.js.map +1 -1
  18. package/out-tsc/src/list/ShortcutList.js +1 -1
  19. package/out-tsc/src/list/ShortcutList.js.map +1 -1
  20. package/out-tsc/src/live/ContactChat.js +12 -321
  21. package/out-tsc/src/live/ContactChat.js.map +1 -1
  22. package/out-tsc/src/simulator/Simulator.js +432 -541
  23. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  24. package/out-tsc/src/store/AppState.js +33 -0
  25. package/out-tsc/src/store/AppState.js.map +1 -1
  26. package/out-tsc/test/temba-appstate-node-sorting.test.js +430 -0
  27. package/out-tsc/test/temba-appstate-node-sorting.test.js.map +1 -0
  28. package/out-tsc/test/temba-floating-tab.test.js +0 -9
  29. package/out-tsc/test/temba-floating-tab.test.js.map +1 -1
  30. package/out-tsc/test/temba-flow-editor.test.js +261 -0
  31. package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
  32. package/out-tsc/test/temba-simulator.test.js +51 -32
  33. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  34. package/package.json +1 -1
  35. package/screenshots/truth/actions/add_contact_groups/render/descriptive-group-names.png +0 -0
  36. package/screenshots/truth/actions/add_contact_groups/render/long-group-names.png +0 -0
  37. package/screenshots/truth/actions/add_contact_groups/render/many-groups.png +0 -0
  38. package/screenshots/truth/actions/add_contact_groups/render/multiple-groups.png +0 -0
  39. package/screenshots/truth/actions/add_contact_groups/render/single-group.png +0 -0
  40. package/screenshots/truth/actions/add_contact_urn/render/expression-facebook.png +0 -0
  41. package/screenshots/truth/actions/add_contact_urn/render/expression-phone.png +0 -0
  42. package/screenshots/truth/actions/add_contact_urn/render/facebook-id.png +0 -0
  43. package/screenshots/truth/actions/add_contact_urn/render/instagram-handle.png +0 -0
  44. package/screenshots/truth/actions/add_contact_urn/render/line-id.png +0 -0
  45. package/screenshots/truth/actions/add_contact_urn/render/phone-number.png +0 -0
  46. package/screenshots/truth/actions/add_contact_urn/render/telegram-id.png +0 -0
  47. package/screenshots/truth/actions/add_contact_urn/render/viber-id.png +0 -0
  48. package/screenshots/truth/actions/add_contact_urn/render/wechat-id.png +0 -0
  49. package/screenshots/truth/actions/add_contact_urn/render/whatsapp.png +0 -0
  50. package/screenshots/truth/actions/remove_contact_groups/render/cleanup-groups.png +0 -0
  51. package/screenshots/truth/actions/remove_contact_groups/render/long-descriptive-group-names.png +0 -0
  52. package/screenshots/truth/actions/remove_contact_groups/render/many-groups.png +0 -0
  53. package/screenshots/truth/actions/remove_contact_groups/render/multiple-groups.png +0 -0
  54. package/screenshots/truth/actions/remove_contact_groups/render/remove-from-all-groups.png +0 -0
  55. package/screenshots/truth/actions/remove_contact_groups/render/single-group.png +0 -0
  56. package/screenshots/truth/actions/send_broadcast/render/contacts-only.png +0 -0
  57. package/screenshots/truth/actions/send_broadcast/render/groups-and-contacts.png +0 -0
  58. package/screenshots/truth/actions/send_broadcast/render/groups-only.png +0 -0
  59. package/screenshots/truth/actions/send_broadcast/render/many-groups.png +0 -0
  60. package/screenshots/truth/actions/send_broadcast/render/multiline-text.png +0 -0
  61. package/screenshots/truth/actions/send_email/render/complex-business-email.png +0 -0
  62. package/screenshots/truth/actions/send_email/render/empty-body.png +0 -0
  63. package/screenshots/truth/actions/send_email/render/empty-subject.png +0 -0
  64. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  65. package/screenshots/truth/actions/send_email/render/multiline-body.png +0 -0
  66. package/screenshots/truth/actions/send_email/render/multiple-recipients.png +0 -0
  67. package/screenshots/truth/actions/send_email/render/simple-email.png +0 -0
  68. package/screenshots/truth/actions/send_email/render/with-expressions.png +0 -0
  69. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  70. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  71. package/screenshots/truth/actions/send_msg/render/simple-text.png +0 -0
  72. package/screenshots/truth/actions/send_msg/render/text-with-linebreaks.png +0 -0
  73. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  74. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  75. package/screenshots/truth/actions/send_msg/render/text-without-quick-replies.png +0 -0
  76. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  77. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  78. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  79. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  80. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  81. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  82. package/screenshots/truth/contacts/chat-failure.png +0 -0
  83. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  84. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  85. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  86. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  87. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  88. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  89. package/screenshots/truth/floating-tab/gray.png +0 -0
  90. package/screenshots/truth/floating-tab/green.png +0 -0
  91. package/screenshots/truth/floating-tab/purple.png +0 -0
  92. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  93. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  94. package/screenshots/truth/nodes/split_by_llm/render/summarization.png +0 -0
  95. package/screenshots/truth/nodes/split_by_llm/render/translation-task.png +0 -0
  96. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  97. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  98. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  99. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  102. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  103. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  104. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  105. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  106. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  107. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  108. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  109. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  110. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  111. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  112. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  113. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  114. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  115. package/screenshots/truth/simulator/after-reset.png +0 -0
  116. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  117. package/screenshots/truth/simulator/context-expanded.png +0 -0
  118. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  119. package/screenshots/truth/simulator/event-info.png +0 -0
  120. package/screenshots/truth/simulator/image-attachment.png +0 -0
  121. package/screenshots/truth/simulator/open-initial.png +0 -0
  122. package/screenshots/truth/simulator/quick-replies.png +0 -0
  123. package/src/display/Chat.ts +123 -44
  124. package/src/display/FloatingTab.ts +2 -7
  125. package/src/events/eventRenderers.ts +527 -0
  126. package/src/flow/CanvasNode.ts +18 -1
  127. package/src/flow/Editor.ts +11 -7
  128. package/src/flow/NodeEditor.ts +0 -1
  129. package/src/layout/FloatingWindow.ts +1 -1
  130. package/src/list/ShortcutList.ts +1 -1
  131. package/src/live/ContactChat.ts +17 -376
  132. package/src/simulator/Simulator.ts +492 -564
  133. package/src/store/AppState.ts +56 -0
  134. package/test/temba-appstate-node-sorting.test.ts +506 -0
  135. package/test/temba-floating-tab.test.ts +0 -11
  136. package/test/temba-flow-editor.test.ts +297 -0
  137. package/test/temba-simulator.test.ts +64 -34
@@ -1,12 +1,13 @@
1
1
  import { html, TemplateResult } from 'lit-html';
2
2
  import { RapidElement } from '../RapidElement';
3
3
  import { FloatingWindow } from '../layout/FloatingWindow';
4
- import { FloatingTab } from '../display/FloatingTab';
5
4
  import { css, PropertyValueMap } from 'lit';
6
5
  import { property } from 'lit/decorators.js';
7
- import { postJSON, fromCookie } from '../utils';
6
+ import { postJSON, fromCookie, generateUUIDv7 } from '../utils';
8
7
  import { getStore } from '../store/Store';
9
8
  import { CustomEventType } from '../interfaces';
9
+ import { Chat, ContactEvent, MessageType } from '../display/Chat';
10
+ import { Events, renderEvent } from '../events/eventRenderers';
10
11
 
11
12
  // test attachment URLs
12
13
  const TEST_IMAGES = [
@@ -74,7 +75,6 @@ interface RunContext {
74
75
 
75
76
  interface SimulatorSize {
76
77
  phoneWidth: number;
77
- phoneHeight: number;
78
78
  phoneTotalHeight: number;
79
79
  phoneScreenHeight: number;
80
80
  contextWidth: number;
@@ -94,8 +94,7 @@ interface SimulatorSize {
94
94
  const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
95
95
  small: {
96
96
  phoneWidth: 270,
97
- phoneHeight: 576,
98
- phoneTotalHeight: 576,
97
+ phoneTotalHeight: 530,
99
98
  phoneScreenHeight: 376,
100
99
  contextWidth: 336,
101
100
  contextHeight: 416,
@@ -112,8 +111,7 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
112
111
  },
113
112
  medium: {
114
113
  phoneWidth: 300,
115
- phoneHeight: 720,
116
- phoneTotalHeight: 720,
114
+ phoneTotalHeight: 600,
117
115
  phoneScreenHeight: 470,
118
116
  contextWidth: 420,
119
117
  contextHeight: 520,
@@ -130,8 +128,7 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
130
128
  },
131
129
  large: {
132
130
  phoneWidth: 360,
133
- phoneHeight: 864,
134
- phoneTotalHeight: 864,
131
+ phoneTotalHeight: 700,
135
132
  phoneScreenHeight: 564,
136
133
  contextWidth: 504,
137
134
  contextHeight: 624,
@@ -218,6 +215,7 @@ export class Simulator extends RapidElement {
218
215
 
219
216
  .phone-frame {
220
217
  width: var(--phone-width);
218
+ height: var(--phone-total-height);
221
219
  border-radius: 40px;
222
220
  border: 6px solid #1f2937;
223
221
  box-shadow: 0 0px 30px rgba(0, 0, 0, 0.4);
@@ -322,6 +320,39 @@ export class Simulator extends RapidElement {
322
320
  background: rgba(255, 255, 255, 0.5);
323
321
  }
324
322
 
323
+ /* Custom scrollbar for chat area to allow content to flow behind input */
324
+ .custom-scrollbar-container {
325
+ position: absolute;
326
+ top: 40px;
327
+ bottom: var(--bottom-input-height, 60px);
328
+ right: 4px;
329
+ width: 10px;
330
+ z-index: 20;
331
+ overflow-y: auto;
332
+ overflow-x: hidden;
333
+ }
334
+
335
+ .custom-scrollbar-container::-webkit-scrollbar {
336
+ width: 6px;
337
+ }
338
+
339
+ .custom-scrollbar-container::-webkit-scrollbar-track {
340
+ background: transparent;
341
+ }
342
+
343
+ .custom-scrollbar-container::-webkit-scrollbar-thumb {
344
+ background: rgba(0, 0, 0, 0.2);
345
+ border-radius: 3px;
346
+ }
347
+
348
+ .custom-scrollbar-container::-webkit-scrollbar-thumb:hover {
349
+ background: rgba(0, 0, 0, 0.4);
350
+ }
351
+
352
+ .custom-scrollbar-content {
353
+ width: 100%;
354
+ }
355
+
325
356
  .context-explorer.open {
326
357
  left: var(--context-offset);
327
358
  opacity: 1;
@@ -494,170 +525,95 @@ export class Simulator extends RapidElement {
494
525
  }
495
526
 
496
527
  .phone-screen {
528
+ position: absolute;
529
+ top: 0;
530
+ left: 0;
531
+ right: 0;
532
+ bottom: 0;
497
533
  background: white;
498
- padding: 15px;
499
- padding-top: calc(var(--cutout-height) + 10px);
500
- padding-bottom: 60px;
501
- height: var(--phone-screen-height);
502
- overflow-y: scroll;
503
534
  display: flex;
504
535
  flex-direction: column;
505
- scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
506
- scrollbar-width: thin;
507
- }
508
-
509
- .phone-screen::-webkit-scrollbar {
510
- width: 8px;
511
536
  }
512
537
 
513
- .phone-screen::-webkit-scrollbar-track {
514
- background: transparent;
538
+ temba-chat {
539
+ flex: 1;
540
+ display: flex;
541
+ flex-direction: column;
542
+ min-height: 0;
543
+ --color-chat-in: #e5e5ea;
544
+ --color-chat-out: #007aff;
545
+ --chat-top-padding: calc(var(--cutout-height));
546
+ --chat-bottom-padding: calc(var(--bottom-input-height, 80px) - 10px);
515
547
  }
516
548
 
517
- .phone-screen::-webkit-scrollbar-thumb {
518
- background: rgba(0, 0, 0, 0.2);
519
- border-radius: 4px;
549
+ .bottom-input-container {
550
+ position: absolute;
551
+ bottom: 0px;
552
+ left: 0px;
553
+ right: 0px;
554
+ z-index: 10;
520
555
  }
521
556
 
522
- .phone-screen::-webkit-scrollbar-thumb:hover {
523
- background: rgba(0, 0, 0, 0.3);
557
+ .bottom-input-container::before {
558
+ content: '';
559
+ position: absolute;
560
+ top: 0;
561
+ left: 0;
562
+ right: 0;
563
+ bottom: 0;
564
+ background: rgba(255, 255, 255, 0.45);
565
+ backdrop-filter: blur(10px);
566
+ -webkit-mask-image: linear-gradient(to bottom, transparent, black 20px);
567
+ mask-image: linear-gradient(to bottom, transparent, black 20px);
568
+ z-index: -1;
524
569
  }
525
570
 
526
- @keyframes messageAppear {
527
- 0% {
528
- opacity: 0;
529
- transform: scale(0.8);
530
- }
531
- 70% {
532
- opacity: 1;
533
- transform: scale(1.05);
534
- }
535
- 100% {
536
- opacity: 1;
537
- transform: scale(1);
538
- }
571
+ .quick-replies-container {
572
+ display: flex;
573
+ flex-wrap: wrap;
574
+ justify-content: center;
575
+ gap: 6px;
576
+ z-index: 9;
539
577
  }
540
578
 
541
- .message {
542
- padding: 10px 14px;
543
- margin-bottom: 8px;
579
+ .quick-reply-btn {
580
+ padding: 4px 8px;
544
581
  border-radius: 18px;
545
- max-width: 70%;
546
- font-size: 13px;
547
- line-height: 1.2;
548
- }
549
- .message.animated {
550
- animation: messageAppear var(--animation-time) ease-out forwards;
551
- opacity: 0;
552
- }
553
- .message.incoming {
554
- background: #e5e5ea;
555
- color: #000;
556
- margin-right: auto;
557
- border-bottom-left-radius: 4px;
558
- }
559
- .message.outgoing {
560
- background: #007aff;
561
- color: white;
562
- margin-left: auto;
563
- text-align: left;
564
- border-bottom-right-radius: 4px;
565
- }
566
- .attachment-wrapper {
567
- max-width: 70%;
568
- margin-bottom: 8px;
569
- display: flex;
570
- flex-direction: column;
571
- gap: 4px;
572
- }
573
- .attachment-wrapper.incoming {
574
- margin-right: auto;
575
- align-items: flex-start;
576
- }
577
- .attachment-wrapper.outgoing {
578
- margin-left: auto;
579
- align-items: flex-end;
580
- }
581
- .attachment-wrapper.animated {
582
- animation: messageAppear var(--animation-time) ease-out forwards;
583
- opacity: 0;
584
- }
585
- .attachment {
586
- border-radius: 12px;
587
- overflow: hidden;
588
- max-width: 100%;
589
- }
590
- .attachment img {
591
- max-width: 100%;
592
- display: block;
593
- border-radius: 12px;
594
- }
595
- .attachment video {
596
- max-width: 100%;
597
- display: block;
598
- border-radius: 12px;
599
- }
600
- .attachment-audio {
601
- display: flex;
602
- align-items: center;
603
- gap: 8px;
604
- padding: 6px;
605
- background: white;
606
- border: 1px solid #e5e5ea;
607
- border-radius: 12px;
608
- min-width: 160px;
609
- }
610
- .attachment-wrapper.outgoing .attachment-audio {
582
+ border: 1px solid var(--color-primary, #007aff);
611
583
  background: white;
612
- border: none;
613
- }
614
- .attachment-audio audio {
615
- flex: 1;
616
- max-height: 30px;
617
- }
618
- .attachment-location {
619
- border-radius: 12px;
620
- overflow: hidden;
621
- }
622
- .event-info {
623
- text-align: center;
584
+ color: var(--color-primary, #007aff);
624
585
  font-size: 11px;
625
- color: #8e8e93;
626
- margin: 4px 0;
627
- padding: 0 10px;
628
- line-height: 1.3;
586
+ cursor: pointer;
587
+ transition: all 0.2s ease;
588
+ flex-shrink: 0;
629
589
  }
630
- .event-info.animated {
631
- animation: messageAppear var(--animation-time) ease-out forwards;
632
- opacity: 0;
590
+
591
+ .quick-reply-btn:hover:not(:disabled) {
592
+ background: var(--color-primary, #007aff);
593
+ color: white;
594
+ }
595
+
596
+ .quick-reply-btn:disabled {
597
+ opacity: 0.5;
598
+ cursor: not-allowed;
633
599
  }
600
+
634
601
  .message-input {
635
- background: linear-gradient(
636
- to top,
637
- rgba(0, 0, 0, 0.1) 0%,
638
- rgba(0, 0, 0, 0.05) 70%,
639
- transparent 100%
640
- );
641
602
  padding: 8px 12px;
642
603
  border-top: none;
643
604
  display: flex;
644
605
  align-items: center;
645
606
  gap: 8px;
646
- position: absolute;
647
- bottom: 0px;
648
- left: 0px;
649
- right: 0px;
650
607
  z-index: 10;
651
608
  }
652
609
  .message-input input {
653
610
  flex: 1;
654
- border: 1px solid #c6c6c8;
611
+ border: 1px solid #c6c6c857;
655
612
  border-radius: 20px;
656
613
  padding: 8px 15px;
657
614
  font-size: 15px;
658
615
  margin-bottom: 5px;
659
- background: white;
660
- border: none;
616
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
661
617
  outline: none;
662
618
  }
663
619
  .message-input input::placeholder {
@@ -668,7 +624,7 @@ export class Simulator extends RapidElement {
668
624
  height: 30px;
669
625
  border-radius: 50%;
670
626
  background: #fff;
671
- border: none;
627
+ border: 1px solid #c6c6c857;
672
628
  display: flex;
673
629
  align-items: center;
674
630
  justify-content: center;
@@ -676,6 +632,7 @@ export class Simulator extends RapidElement {
676
632
  flex-shrink: 0;
677
633
  margin-bottom: 5px;
678
634
  transition: all var(--animation-time) ease;
635
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
679
636
  color: #000;
680
637
  }
681
638
  .attachment-button:hover {
@@ -725,37 +682,6 @@ export class Simulator extends RapidElement {
725
682
  .attachment-menu-item temba-icon {
726
683
  color: #007aff;
727
684
  }
728
- .quick-replies {
729
- display: flex;
730
- flex-wrap: wrap;
731
- justify-content: center;
732
- gap: 8px;
733
- margin-top: 4px;
734
- margin-bottom: 8px;
735
- }
736
- .quick-reply-btn {
737
- background: white;
738
- color: #007aff;
739
- border: 1px solid #007aff;
740
- border-radius: 18px;
741
- padding: 4px 8px;
742
- font-size: 11px;
743
- cursor: pointer;
744
- transition: all var(--animation-time) ease;
745
- white-space: nowrap;
746
- }
747
- .quick-reply-btn:hover {
748
- background: #007aff;
749
- color: white;
750
- cursor: pointer;
751
- }
752
- .quick-reply-btn:active {
753
- transform: scale(0.95);
754
- }
755
- .quick-reply-btn.animated {
756
- animation: messageAppear var(--animation-time) ease-out forwards;
757
- opacity: 0;
758
- }
759
685
  `;
760
686
  }
761
687
 
@@ -772,9 +698,10 @@ export class Simulator extends RapidElement {
772
698
  size: 'small' | 'medium' | 'large';
773
699
 
774
700
  @property({ type: Array })
775
- private events: Event[] = [];
701
+ private events: ContactEvent[] = [];
776
702
 
777
703
  private previousEventCount = 0;
704
+ private chat: Chat = null;
778
705
 
779
706
  @property({ type: Object })
780
707
  private session: Session | null = null;
@@ -869,10 +796,130 @@ export class Simulator extends RapidElement {
869
796
  return config.contextWidth + config.contextOffset - config.phoneWidth;
870
797
  }
871
798
 
799
+ public connectedCallback() {
800
+ super.connectedCallback();
801
+ }
802
+
803
+ protected firstUpdated(
804
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
805
+ ): void {
806
+ super.firstUpdated(changes);
807
+ this.chat = this.shadowRoot.querySelector('temba-chat');
808
+
809
+ // if we have events that were collected before chat was ready, add them now
810
+ if (this.chat && this.events.length > 0) {
811
+ this.chat.addMessages(this.events, null, true);
812
+ }
813
+
814
+ this.setupCustomScrollbar();
815
+ }
816
+
817
+ private setupCustomScrollbar() {
818
+ const chat = this.shadowRoot?.querySelector('temba-chat') as Chat;
819
+ const scrollContainer = this.shadowRoot?.querySelector(
820
+ '.custom-scrollbar-container'
821
+ ) as HTMLElement;
822
+ const scrollContent = this.shadowRoot?.querySelector(
823
+ '.custom-scrollbar-content'
824
+ ) as HTMLElement;
825
+
826
+ if (!chat || !scrollContainer || !scrollContent) return;
827
+
828
+ chat.updateComplete.then(() => {
829
+ const chatScroll = chat.shadowRoot?.querySelector(
830
+ '.scroll'
831
+ ) as HTMLElement;
832
+ if (!chatScroll) return;
833
+
834
+ let ignoreScroll = false;
835
+
836
+ // Sync from chat to custom scrollbar
837
+ chatScroll.addEventListener('scroll', () => {
838
+ if (!ignoreScroll) {
839
+ ignoreScroll = true;
840
+ // Chat: 0 (bottom) ... -Max (top) (Negative scrolling)
841
+ // Custom: Max (bottom) ... 0 (top) (Positive scrolling)
842
+
843
+ const maxScroll =
844
+ scrollContainer.scrollHeight - scrollContainer.clientHeight;
845
+ // Math.abs to handle negative scrollTop
846
+ const distanceFromBottom = Math.abs(chatScroll.scrollTop);
847
+ const newCustomScrollTop = maxScroll - distanceFromBottom;
848
+
849
+ scrollContainer.scrollTop = newCustomScrollTop;
850
+
851
+ requestAnimationFrame(() => (ignoreScroll = false));
852
+ }
853
+ });
854
+
855
+ // Sync from custom scrollbar to chat
856
+ scrollContainer.addEventListener('scroll', () => {
857
+ if (!ignoreScroll) {
858
+ ignoreScroll = true;
859
+
860
+ const maxScroll =
861
+ scrollContainer.scrollHeight - scrollContainer.clientHeight;
862
+ const distanceFromBottom = maxScroll - scrollContainer.scrollTop;
863
+
864
+ // chat scrollTop should be -distanceFromBottom
865
+ chatScroll.scrollTop = -distanceFromBottom;
866
+
867
+ requestAnimationFrame(() => (ignoreScroll = false));
868
+ }
869
+ });
870
+
871
+ // Sync height
872
+ const syncHeight = () => {
873
+ const chatMaxScroll = chatScroll.scrollHeight - chatScroll.clientHeight;
874
+ const customClientHeight = scrollContainer.clientHeight;
875
+
876
+ // ensure minimum height
877
+ if (chatMaxScroll <= 0) {
878
+ scrollContent.style.height = '100%';
879
+ return;
880
+ }
881
+
882
+ const newHeight = chatMaxScroll + customClientHeight;
883
+ scrollContent.style.height = `${newHeight}px`;
884
+
885
+ // If we were effectively at the bottom, stay at the bottom
886
+ // This is a heuristic, assuming if we're close enough we're "at bottom"
887
+ // But the Chat component handles scrollToBottom on new messages, which fires scroll event,
888
+ // which updates us. So we might not need to force it here unless resize happens without message.
889
+ if (Math.abs(chatScroll.scrollTop) < 5) {
890
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
891
+ }
892
+ };
893
+
894
+ // Observe changes
895
+ const observer = new MutationObserver(syncHeight);
896
+ observer.observe(chatScroll, {
897
+ childList: true,
898
+ subtree: true,
899
+ attributes: true
900
+ });
901
+
902
+ const resizeObserver = new ResizeObserver(syncHeight);
903
+ resizeObserver.observe(chatScroll);
904
+
905
+ // Initial sync
906
+ syncHeight();
907
+ });
908
+ }
909
+
872
910
  protected updated(
873
911
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
874
912
  ): void {
875
913
  super.updated(changes);
914
+
915
+ if (
916
+ changes.has('currentQuickReplies') ||
917
+ changes.has('keyboardVisible') ||
918
+ changes.has('attachmentMenuOpen')
919
+ ) {
920
+ this.updateBottomInputHeight();
921
+ }
922
+
876
923
  if (changes.has('flow') && this.flow) {
877
924
  this.endpoint = `/flow/simulate/${this.flow}/`;
878
925
  }
@@ -978,8 +1025,13 @@ export class Simulator extends RapidElement {
978
1025
  this.isVisible = true;
979
1026
  getStore().getState().setSimulatorActive(true);
980
1027
 
1028
+ // ensure chat component is available
1029
+ if (!this.chat) {
1030
+ this.chat = this.shadowRoot.querySelector('temba-chat');
1031
+ }
1032
+
981
1033
  // start the simulation if we haven't already
982
- if (this.events.length === 0) {
1034
+ if (!this.session) {
983
1035
  this.startFlow();
984
1036
  }
985
1037
  }
@@ -1005,20 +1057,37 @@ export class Simulator extends RapidElement {
1005
1057
  this.updateRunContext(response.json as RunContext);
1006
1058
  } catch (error) {
1007
1059
  console.error('Failed to start simulation:', error);
1008
- this.events = [
1009
- ...this.events,
1010
- {
1011
- type: 'error',
1012
- created_on: now,
1013
- text: 'Failed to start simulation'
1014
- } as any
1015
- ];
1060
+ const errorEvent = {
1061
+ uuid: generateUUIDv7(),
1062
+ type: 'error',
1063
+ created_on: new Date(now),
1064
+ _rendered: {
1065
+ html: html`<p>Failed to start simulation</p>`,
1066
+ type: MessageType.Error
1067
+ }
1068
+ } as ContactEvent;
1069
+ if (this.chat) {
1070
+ this.chat.addMessages([errorEvent], null, true);
1071
+ } else {
1072
+ this.events = [...this.events, errorEvent];
1073
+ }
1016
1074
  }
1017
1075
  }
1018
1076
 
1019
- private updateRunContext(runContext: RunContext, msgInEvt?: Event) {
1077
+ private updateRunContext(runContext: RunContext, msgInEvt?: ContactEvent) {
1078
+ const newEvents: ContactEvent[] = [];
1079
+
1080
+ // add the user's message if provided
1020
1081
  if (msgInEvt) {
1021
- this.events = [...this.events, msgInEvt];
1082
+ // ensure it has a UUID
1083
+ if (!msgInEvt.uuid) {
1084
+ msgInEvt.uuid = generateUUIDv7();
1085
+ }
1086
+ // ensure created_on is a Date object
1087
+ if (typeof msgInEvt.created_on === 'string') {
1088
+ msgInEvt.created_on = new Date(msgInEvt.created_on);
1089
+ }
1090
+ newEvents.push(msgInEvt);
1022
1091
  }
1023
1092
 
1024
1093
  if (runContext.session) {
@@ -1035,20 +1104,70 @@ export class Simulator extends RapidElement {
1035
1104
  this.context = runContext.context;
1036
1105
  }
1037
1106
 
1107
+ // extract quick replies from the most recent sprint
1108
+ this.currentQuickReplies = [];
1109
+
1038
1110
  if (runContext.events && runContext.events.length > 0) {
1039
- this.events = [...this.events, ...runContext.events];
1111
+ for (const rawEvent of runContext.events) {
1112
+ // skip msg_received events from the server since we already added the user's message
1113
+ if (rawEvent.type === 'msg_received') {
1114
+ continue;
1115
+ }
1116
+
1117
+ // skip msg_created events without a proper msg property
1118
+ if (rawEvent.type === 'msg_created' && !(rawEvent as any).msg) {
1119
+ continue;
1120
+ }
1040
1121
 
1041
- // extract quick replies from the most recent sprint
1042
- this.currentQuickReplies = [];
1043
- for (const event of runContext.events) {
1044
- if (event.type === 'msg_created' && event.msg?.quick_replies) {
1045
- this.currentQuickReplies = event.msg.quick_replies;
1122
+ // convert to ContactEvent
1123
+ const event: ContactEvent = {
1124
+ ...rawEvent,
1125
+ uuid: rawEvent.uuid || generateUUIDv7(),
1126
+ created_on:
1127
+ typeof rawEvent.created_on === 'string'
1128
+ ? new Date(rawEvent.created_on)
1129
+ : rawEvent.created_on
1130
+ } as ContactEvent;
1131
+
1132
+ // pre-render non-message events
1133
+ this.prerenderEvent(event);
1134
+
1135
+ // extract quick replies from msg_created events
1136
+ if (event.type === 'msg_created' && (event as any).msg?.quick_replies) {
1137
+ this.currentQuickReplies = (event as any).msg.quick_replies;
1046
1138
  }
1139
+
1140
+ const isMessage = event.type === 'msg_created';
1141
+ const msg = (event as any).msg;
1142
+
1143
+ // Check if the event should be displayed.
1144
+ // 1. If it's a message, it must have text or attachments
1145
+ if (isMessage) {
1146
+ const hasText = msg.text && msg.text.trim().length > 0;
1147
+ const hasAttachments = msg.attachments && msg.attachments.length > 0;
1148
+ if (!hasText && !hasAttachments) {
1149
+ continue;
1150
+ }
1151
+ }
1152
+ // 2. If it's not a message, it must have been rendered by prerenderEvent
1153
+ else if (!event._rendered) {
1154
+ continue;
1155
+ }
1156
+
1157
+ newEvents.push(event);
1047
1158
  }
1048
1159
  }
1049
1160
 
1161
+ // add all new events to chat component if it exists
1162
+ if (this.chat) {
1163
+ this.chat.addMessages(newEvents, null, true);
1164
+ } else {
1165
+ // fallback: store events and add them once chat is ready
1166
+ this.events = [...this.events, ...newEvents];
1167
+ }
1168
+
1050
1169
  this.sprinting = false;
1051
- this.requestUpdate();
1170
+ this.requestUpdate(); // trigger re-render for quick replies
1052
1171
  this.scrollToBottom();
1053
1172
  this.updateActivity();
1054
1173
  }
@@ -1127,6 +1246,19 @@ export class Simulator extends RapidElement {
1127
1246
  }
1128
1247
 
1129
1248
  private scrollToBottom() {
1249
+ if (this.chat) {
1250
+ // chat component handles scrolling, but we still need to focus input
1251
+ this.chat.scrollToBottom();
1252
+ setTimeout(() => {
1253
+ const input = this.shadowRoot?.querySelector(
1254
+ '.message-input input'
1255
+ ) as HTMLInputElement;
1256
+ if (input) {
1257
+ input.focus();
1258
+ }
1259
+ }, 50);
1260
+ return;
1261
+ }
1130
1262
  // wait for render, then scroll to bottom
1131
1263
  setTimeout(() => {
1132
1264
  const screen = this.shadowRoot?.querySelector('.phone-screen');
@@ -1146,16 +1278,52 @@ export class Simulator extends RapidElement {
1146
1278
  }, 50);
1147
1279
  }
1148
1280
 
1281
+ private prerenderEvent(event: ContactEvent) {
1282
+ // skip if already rendered or is a message event
1283
+ if (
1284
+ event._rendered ||
1285
+ event.type === Events.MSG_CREATED ||
1286
+ event.type === Events.MSG_RECEIVED
1287
+ ) {
1288
+ return;
1289
+ }
1290
+
1291
+ // handle simulator-specific events (errors, warnings, failures)
1292
+ if (event.type === 'error' || event.type === 'failure') {
1293
+ event._rendered = {
1294
+ html: renderEvent(event, true),
1295
+ type: MessageType.Error
1296
+ };
1297
+ return;
1298
+ }
1299
+
1300
+ if (event.type === 'warning') {
1301
+ event._rendered = {
1302
+ html: renderEvent(event, true),
1303
+ type: MessageType.Note
1304
+ };
1305
+ return;
1306
+ }
1307
+
1308
+ // try to render as a standard event
1309
+ const rendered = renderEvent(event, true);
1310
+ if (rendered) {
1311
+ event._rendered = {
1312
+ html: rendered,
1313
+ type: MessageType.Inline
1314
+ };
1315
+ }
1316
+ }
1317
+
1149
1318
  private handleClose() {
1150
1319
  const phoneWindow = this.shadowRoot.getElementById(
1151
1320
  'phone-window'
1152
1321
  ) as FloatingWindow;
1153
- phoneWindow.hide();
1322
+ // phoneWindow.hide();
1323
+
1324
+ phoneWindow.handleClose();
1154
1325
  this.isVisible = false;
1155
1326
  getStore().getState().setSimulatorActive(false);
1156
-
1157
- const phoneTab = this.shadowRoot.getElementById('phone-tab') as FloatingTab;
1158
- phoneTab.hidden = false;
1159
1327
  }
1160
1328
 
1161
1329
  private handleReset() {
@@ -1168,6 +1336,11 @@ export class Simulator extends RapidElement {
1168
1336
  this.previousEventCount = 0;
1169
1337
  this.currentQuickReplies = [];
1170
1338
 
1339
+ // reset chat component
1340
+ if (this.chat) {
1341
+ this.chat.reset();
1342
+ }
1343
+
1171
1344
  // Clear simulator activity data
1172
1345
  getStore().getState().updateSimulatorActivity({
1173
1346
  segments: {},
@@ -1387,20 +1560,36 @@ export class Simulator extends RapidElement {
1387
1560
  this.attachmentMenuOpen = false;
1388
1561
 
1389
1562
  const now = new Date().toISOString();
1390
- const msgInEvt: Event = {
1391
- uuid: crypto.randomUUID(),
1563
+
1564
+ // create the event for the API (with ISO string date)
1565
+ const msgInEvtForAPI = {
1566
+ uuid: generateUUIDv7(),
1392
1567
  type: 'msg_received',
1393
1568
  created_on: now,
1394
1569
  msg: {
1395
- uuid: crypto.randomUUID(),
1570
+ uuid: generateUUIDv7(),
1396
1571
  text: text || '',
1397
1572
  urn: this.contact.urns[0],
1398
- attachments: attachment ? [attachment] : []
1573
+ direction: 'in',
1574
+ type: 'text',
1575
+ attachments: attachment ? [attachment] : [],
1576
+ quick_replies: [],
1577
+ channel: { uuid: generateUUIDv7(), name: 'Simulator' }
1399
1578
  }
1400
1579
  };
1401
1580
 
1402
- // show user's message immediately
1403
- this.events = [...this.events, msgInEvt];
1581
+ // create the ContactEvent for display (with Date object)
1582
+ const msgInEvt = {
1583
+ ...msgInEvtForAPI,
1584
+ created_on: new Date(now)
1585
+ } as ContactEvent;
1586
+
1587
+ // show user's message immediately via chat component
1588
+ if (this.chat) {
1589
+ this.chat.addMessages([msgInEvt], null, true);
1590
+ } else {
1591
+ this.events = [...this.events, msgInEvt];
1592
+ }
1404
1593
  this.requestUpdate();
1405
1594
  this.scrollToBottom();
1406
1595
 
@@ -1409,7 +1598,7 @@ export class Simulator extends RapidElement {
1409
1598
  contact: this.contact,
1410
1599
  resume: {
1411
1600
  type: 'msg',
1412
- event: msgInEvt,
1601
+ event: msgInEvtForAPI,
1413
1602
  resumed_on: now
1414
1603
  }
1415
1604
  };
@@ -1424,14 +1613,20 @@ export class Simulator extends RapidElement {
1424
1613
  this.updateRunContext(response.json as RunContext, null);
1425
1614
  } catch (error) {
1426
1615
  console.error('Failed to resume simulation:', error);
1427
- this.events = [
1428
- ...this.events,
1429
- {
1430
- type: 'error',
1431
- created_on: now,
1432
- text: 'Failed to send message'
1433
- } as any
1434
- ];
1616
+ const errorEvent = {
1617
+ uuid: generateUUIDv7(),
1618
+ type: 'error',
1619
+ created_on: new Date(now),
1620
+ _rendered: {
1621
+ html: html`<p>Failed to send message</p>`,
1622
+ type: MessageType.Error
1623
+ }
1624
+ } as ContactEvent;
1625
+ if (this.chat) {
1626
+ this.chat.addMessages([errorEvent], null, true);
1627
+ } else {
1628
+ this.events = [...this.events, errorEvent];
1629
+ }
1435
1630
  this.sprinting = false;
1436
1631
  }
1437
1632
  }
@@ -1451,9 +1646,9 @@ export class Simulator extends RapidElement {
1451
1646
  this.inputValue = input.value;
1452
1647
  }
1453
1648
 
1454
- private handleQuickReply(quickReply: string) {
1455
- if (!this.sprinting) {
1456
- this.resume(quickReply);
1649
+ private handleQuickReplyClick(text: string) {
1650
+ if (!this.sprinting && text) {
1651
+ this.resume(text);
1457
1652
  }
1458
1653
  }
1459
1654
 
@@ -1508,310 +1703,16 @@ export class Simulator extends RapidElement {
1508
1703
  }
1509
1704
  }
1510
1705
 
1511
- private getEventDescription(event: Event): string | null {
1512
- switch (event.type) {
1513
- case 'contact_groups_changed': {
1514
- const groups = (event as any).groups_added || [];
1515
- const removedGroups = (event as any).groups_removed || [];
1516
- if (groups.length > 0) {
1517
- const groupNames = groups.map((g: any) => `"${g.name}"`).join(', ');
1518
- return `Added to ${groupNames}`;
1519
- }
1520
- if (removedGroups.length > 0) {
1521
- const groupNames = removedGroups
1522
- .map((g: any) => `"${g.name}"`)
1523
- .join(', ');
1524
- return `Removed from ${groupNames}`;
1525
- }
1526
- break;
1527
- }
1528
- case 'contact_field_changed': {
1529
- const field = (event as any).field;
1530
- const value = (event as any).value;
1531
- const valueText = value ? value.text || value : '';
1532
- if (field) {
1533
- if (valueText) {
1534
- return `Set contact "${field.name}" to "${valueText}"`;
1535
- } else {
1536
- return `Cleared contact "${field.name}"`;
1537
- }
1538
- }
1539
- break;
1540
- }
1541
- case 'contact_language_changed':
1542
- return `Set preferred language to "${(event as any).language}"`;
1543
- case 'contact_name_changed':
1544
- return `Set contact name to "${(event as any).name}"`;
1545
- case 'contact_status_changed':
1546
- return `Set status to "${(event as any).status}"`;
1547
- case 'contact_urns_changed':
1548
- return `Added a URN for the contact`;
1549
- case 'input_labels_added': {
1550
- const labels = (event as any).labels || [];
1551
- if (labels.length > 0) {
1552
- const labelNames = labels.map((l: any) => `"${l.name}"`).join(', ');
1553
- return `Message labeled with ${labelNames}`;
1554
- }
1555
- break;
1556
- }
1557
- case 'run_result_changed':
1558
- return `Set result "${(event as any).name}" to "${
1559
- (event as any).value
1560
- }"`;
1561
- case 'run_started':
1562
- case 'flow_entered': {
1563
- const flow = (event as any).flow;
1564
- if (flow) {
1565
- return `Entered flow "${flow.name}"`;
1566
- }
1567
- break;
1568
- }
1569
- case 'run_ended': {
1570
- const flow = (event as any).flow;
1571
- if (flow) {
1572
- return `Exited flow "${flow.name}"`;
1573
- }
1574
- break;
1575
- }
1576
- case 'email_created':
1577
- case 'email_sent': {
1578
- const recipients = (event as any).to || (event as any).addresses || [];
1579
- const subject = (event as any).subject;
1580
- const recipientList = recipients
1581
- .map((r: string) => `"${r}"`)
1582
- .join(', ');
1583
- return `Sent email to ${recipientList} with subject "${subject}"`;
1584
- }
1585
- case 'broadcast_created': {
1586
- const translations = (event as any).translations;
1587
- const baseLanguage = (event as any).base_language;
1588
- if (translations && translations[baseLanguage]) {
1589
- return `Sent broadcast: "${translations[baseLanguage].text}"`;
1590
- }
1591
- return `Sent broadcast`;
1706
+ private updateBottomInputHeight() {
1707
+ requestAnimationFrame(() => {
1708
+ const bottomContainer = this.shadowRoot?.querySelector(
1709
+ '.bottom-input-container'
1710
+ ) as HTMLElement;
1711
+ if (bottomContainer) {
1712
+ const height = bottomContainer.offsetHeight;
1713
+ this.style.setProperty('--bottom-input-height', `${height}px`);
1592
1714
  }
1593
- case 'session_triggered': {
1594
- const flow = (event as any).flow;
1595
- if (flow) {
1596
- return `Started somebody else in "${flow.name}"`;
1597
- }
1598
- break;
1599
- }
1600
- case 'ticket_opened': {
1601
- const ticket = (event as any).ticket;
1602
- if (ticket && ticket.topic) {
1603
- return `Ticket opened with topic "${ticket.topic.name}"`;
1604
- }
1605
- return `Ticket opened`;
1606
- }
1607
- case 'resthook_called':
1608
- return `Triggered flow event "${(event as any).resthook}"`;
1609
- case 'webhook_called':
1610
- return `Called ${(event as any).url}`;
1611
- case 'service_called': {
1612
- const service = (event as any).service;
1613
- if (service === 'classifier') {
1614
- return `Called classifier`;
1615
- }
1616
- return `Called ${service}`;
1617
- }
1618
- case 'airtime_transferred': {
1619
- const amount = (event as any).actual_amount;
1620
- const currency = (event as any).currency;
1621
- const recipient = (event as any).recipient;
1622
- if (amount && currency && recipient) {
1623
- return `Transferred ${amount} ${currency} to ${recipient}`;
1624
- }
1625
- break;
1626
- }
1627
- case 'info':
1628
- return (event as any).text;
1629
- case 'warning':
1630
- return `⚠️ ${(event as any).text}`;
1631
- }
1632
- return null;
1633
- }
1634
-
1635
- private renderAttachment(attachment: string): TemplateResult {
1636
- // parse attachment format: "type/subtype:url" or "geo:lat,long"
1637
- const parts = attachment.split(':');
1638
- const type = parts[0];
1639
- const content = parts.slice(1).join(':'); // rejoin in case url has colons
1640
-
1641
- if (type === 'geo') {
1642
- // use temba-thumbnail for location to get map image
1643
- return html`
1644
- <div class="attachment-location">
1645
- <temba-thumbnail attachment="${attachment}"></temba-thumbnail>
1646
- </div>
1647
- `;
1648
- } else if (type.startsWith('image/')) {
1649
- // custom image rendering
1650
- return html`
1651
- <div class="attachment">
1652
- <img src="${content}" alt="Image attachment" />
1653
- </div>
1654
- `;
1655
- } else if (type.startsWith('video/')) {
1656
- // custom video rendering
1657
- return html`
1658
- <div class="attachment">
1659
- <video controls>
1660
- <source src="${content}" type="${type}" />
1661
- </video>
1662
- </div>
1663
- `;
1664
- } else if (type.startsWith('audio/')) {
1665
- // custom audio rendering
1666
- return html`
1667
- <div class="attachment">
1668
- <div class="attachment-audio">
1669
- <audio controls>
1670
- <source src="${content}" type="${type}" />
1671
- </audio>
1672
- </div>
1673
- </div>
1674
- `;
1675
- }
1676
-
1677
- // fallback for unknown types
1678
- return html`
1679
- <div class="attachment">
1680
- <span>Attachment</span>
1681
- </div>
1682
- `;
1683
- }
1684
-
1685
- private renderMessages(): TemplateResult {
1686
- if (this.events.length === 0) {
1687
- return html`
1688
- <div class="message incoming">👋 Welcome! Starting simulation...</div>
1689
- `;
1690
- }
1691
-
1692
- const eventTemplates = this.events.map((event, index) => {
1693
- // only animate messages that are new (beyond previous count)
1694
- const isNew = index >= this.previousEventCount;
1695
- const animatedClass = isNew ? 'animated' : '';
1696
- // stagger animations for new messages
1697
- const animationDelay = isNew
1698
- ? `${(index - this.previousEventCount) * 0.2}s`
1699
- : '0s';
1700
-
1701
- if (event.type === 'msg_received' && event.msg) {
1702
- const hasAttachments =
1703
- event.msg.attachments && event.msg.attachments.length > 0;
1704
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1705
-
1706
- return html`
1707
- ${hasAttachments
1708
- ? html`
1709
- <div
1710
- class="attachment-wrapper outgoing ${animatedClass}"
1711
- style="animation-delay: ${animationDelay}"
1712
- >
1713
- ${event.msg.attachments.map((att: string) =>
1714
- this.renderAttachment(att)
1715
- )}
1716
- </div>
1717
- `
1718
- : html``}
1719
- ${hasText
1720
- ? html`
1721
- <div
1722
- class="message outgoing ${animatedClass}"
1723
- style="animation-delay: ${animationDelay}"
1724
- >
1725
- ${event.msg.text}
1726
- </div>
1727
- `
1728
- : html``}
1729
- `;
1730
- } else if (event.type === 'msg_created' && event.msg) {
1731
- const hasAttachments =
1732
- event.msg.attachments && event.msg.attachments.length > 0;
1733
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1734
-
1735
- return html`
1736
- ${hasAttachments
1737
- ? html`
1738
- <div
1739
- class="attachment-wrapper incoming ${animatedClass}"
1740
- style="animation-delay: ${animationDelay}"
1741
- >
1742
- ${event.msg.attachments.map((att: string) =>
1743
- this.renderAttachment(att)
1744
- )}
1745
- </div>
1746
- `
1747
- : html``}
1748
- ${hasText
1749
- ? html`
1750
- <div
1751
- class="message incoming ${animatedClass}"
1752
- style="animation-delay: ${animationDelay}"
1753
- >
1754
- ${event.msg.text}
1755
- </div>
1756
- `
1757
- : html``}
1758
- `;
1759
- } else if (event.type === 'error') {
1760
- return html`
1761
- <div
1762
- class="message incoming ${animatedClass}"
1763
- style="background: #ff4444; color: white; animation-delay: ${animationDelay}"
1764
- >
1765
- ⚠️ ${(event as any).text || 'An error occurred'}
1766
- </div>
1767
- `;
1768
- } else {
1769
- // check if this is an event we should display
1770
- const description = this.getEventDescription(event);
1771
- if (description) {
1772
- return html`
1773
- <div
1774
- class="event-info ${animatedClass}"
1775
- style="animation-delay: ${animationDelay}"
1776
- >
1777
- ${description}
1778
- </div>
1779
- `;
1780
- }
1781
- }
1782
- return html``;
1783
1715
  });
1784
-
1785
- // render quick replies at the end if we have any from the most recent sprint
1786
- const hasQuickReplies = this.currentQuickReplies.length > 0;
1787
- const quickRepliesAnimationDelay =
1788
- this.events.length >= this.previousEventCount
1789
- ? `${(this.events.length - this.previousEventCount) * 0.2}s`
1790
- : '0s';
1791
-
1792
- return html`
1793
- ${eventTemplates}
1794
- ${hasQuickReplies
1795
- ? html`
1796
- <div
1797
- class="quick-replies animated"
1798
- style="animation-delay: ${quickRepliesAnimationDelay}"
1799
- >
1800
- ${this.currentQuickReplies.map(
1801
- (qr: any) => html`
1802
- <button
1803
- class="quick-reply-btn animated"
1804
- style="animation-delay: ${quickRepliesAnimationDelay}"
1805
- @click=${() => this.handleQuickReply(qr.text)}
1806
- >
1807
- ${qr.text}
1808
- </button>
1809
- `
1810
- )}
1811
- </div>
1812
- `
1813
- : html``}
1814
- `;
1815
1716
  }
1816
1717
 
1817
1718
  protected render(): TemplateResult {
@@ -1835,17 +1736,19 @@ export class Simulator extends RapidElement {
1835
1736
  --cutout-island-width: ${config.cutoutIslandWidth}px;
1836
1737
  --cutout-island-height: ${config.cutoutIslandHeight}px;
1837
1738
  --cutout-island-top: ${config.cutoutIslandTop}px;
1739
+ --animation-time: ${this.animationTime}ms;
1838
1740
  `;
1839
1741
 
1840
1742
  return html`
1841
1743
  <temba-floating-window
1744
+ style="--transition-duration: ${this.animationTime}ms"
1842
1745
  id="phone-window"
1843
1746
  width="${this.windowWidth}"
1844
1747
  leftBoundaryMargin="${this.leftBoundaryMargin}"
1845
1748
  bottomBoundaryMargin="${config.windowPadding}"
1846
1749
  topBoundaryMargin="${config.windowPadding}"
1847
1750
  height="${config.phoneTotalHeight}"
1848
- top="60"
1751
+ top="0"
1849
1752
  chromeless
1850
1753
  >
1851
1754
  <div class="phone-simulator" style="${styleVars}">
@@ -1903,56 +1806,80 @@ export class Simulator extends RapidElement {
1903
1806
  <div class="dynamic-island"></div>
1904
1807
  </div>
1905
1808
  </div>
1906
- <div class="phone-screen">${this.renderMessages()}</div>
1907
- <div class="message-input">
1908
- <button
1909
- class="attachment-button"
1910
- @click=${this.handleToggleAttachmentMenu}
1911
- ?disabled=${this.sprinting}
1912
- >
1913
- <temba-icon name="plus" size="1.5"></temba-icon>
1914
- </button>
1915
- <input
1916
- type="text"
1917
- placeholder="Enter Message"
1918
- .value=${this.inputValue}
1919
- @input=${this.handleInput}
1920
- @keyup=${this.handleKeyUp}
1921
- ?disabled=${this.sprinting}
1922
- />
1923
- <div
1924
- class="attachment-menu ${this.attachmentMenuOpen ? 'open' : ''}"
1925
- >
1926
- <div
1927
- class="attachment-menu-item"
1928
- @click=${() => this.handleSendAttachment('image')}
1929
- >
1930
- <temba-icon name="attachment_image" size="1.2"></temba-icon>
1931
- <span>Image</span>
1932
- </div>
1933
- <div
1934
- class="attachment-menu-item"
1935
- @click=${() => this.handleSendAttachment('video')}
1936
- >
1937
- <temba-icon name="attachment_video" size="1.2"></temba-icon>
1938
- <span>Video</span>
1939
- </div>
1940
- <div
1941
- class="attachment-menu-item"
1942
- @click=${() => this.handleSendAttachment('audio')}
1809
+ <temba-chat class="phone-screen" .showTimestamps=${false}>
1810
+ </temba-chat>
1811
+ <div class="custom-scrollbar-container">
1812
+ <div class="custom-scrollbar-content"></div>
1813
+ </div>
1814
+
1815
+ <div class="bottom-input-container">
1816
+ ${this.currentQuickReplies.length > 0
1817
+ ? html`<div class="quick-replies-container">
1818
+ ${this.currentQuickReplies.map(
1819
+ (qr) => html`
1820
+ <button
1821
+ class="quick-reply-btn"
1822
+ @click=${() => this.handleQuickReplyClick(qr.text)}
1823
+ ?disabled=${this.sprinting}
1824
+ >
1825
+ ${qr.text}
1826
+ </button>
1827
+ `
1828
+ )}
1829
+ </div>`
1830
+ : null}
1831
+ <div class="message-input">
1832
+ <button
1833
+ class="attachment-button"
1834
+ @click=${this.handleToggleAttachmentMenu}
1835
+ ?disabled=${this.sprinting}
1943
1836
  >
1944
- <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1945
- <span>Audio</span>
1946
- </div>
1837
+ <temba-icon name="plus" size="1.5"></temba-icon>
1838
+ </button>
1839
+ <input
1840
+ type="text"
1841
+ placeholder="Enter Message"
1842
+ .value=${this.inputValue}
1843
+ @input=${this.handleInput}
1844
+ @keyup=${this.handleKeyUp}
1845
+ ?disabled=${this.sprinting}
1846
+ />
1947
1847
  <div
1948
- class="attachment-menu-item"
1949
- @click=${() => this.handleSendAttachment('location')}
1848
+ class="attachment-menu ${this.attachmentMenuOpen
1849
+ ? 'open'
1850
+ : ''}"
1950
1851
  >
1951
- <temba-icon
1952
- name="attachment_location"
1953
- size="1.2"
1954
- ></temba-icon>
1955
- <span>Location</span>
1852
+ <div
1853
+ class="attachment-menu-item"
1854
+ @click=${() => this.handleSendAttachment('image')}
1855
+ >
1856
+ <temba-icon name="attachment_image" size="1.2"></temba-icon>
1857
+ <span>Image</span>
1858
+ </div>
1859
+ <div
1860
+ class="attachment-menu-item"
1861
+ @click=${() => this.handleSendAttachment('video')}
1862
+ >
1863
+ <temba-icon name="attachment_video" size="1.2"></temba-icon>
1864
+ <span>Video</span>
1865
+ </div>
1866
+ <div
1867
+ class="attachment-menu-item"
1868
+ @click=${() => this.handleSendAttachment('audio')}
1869
+ >
1870
+ <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1871
+ <span>Audio</span>
1872
+ </div>
1873
+ <div
1874
+ class="attachment-menu-item"
1875
+ @click=${() => this.handleSendAttachment('location')}
1876
+ >
1877
+ <temba-icon
1878
+ name="attachment_location"
1879
+ size="1.2"
1880
+ ></temba-icon>
1881
+ <span>Location</span>
1882
+ </div>
1956
1883
  </div>
1957
1884
  </div>
1958
1885
  </div>
@@ -2001,6 +1928,7 @@ export class Simulator extends RapidElement {
2001
1928
  icon="simulator"
2002
1929
  label="Phone Simulator"
2003
1930
  color="#10b981"
1931
+ .hidden=${this.isVisible}
2004
1932
  @temba-button-clicked=${this.handleShow}
2005
1933
  ></temba-floating-tab>
2006
1934
  `;