@nyaruka/temba-components 0.136.1 → 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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/demo/components/webchat/example.html +2 -2
  3. package/dist/temba-components.js +487 -551
  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/events/eventRenderers.js +442 -0
  8. package/out-tsc/src/events/eventRenderers.js.map +1 -0
  9. package/out-tsc/src/flow/Editor.js +3 -2
  10. package/out-tsc/src/flow/Editor.js.map +1 -1
  11. package/out-tsc/src/flow/NodeEditor.js +0 -1
  12. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  13. package/out-tsc/src/list/ShortcutList.js +1 -1
  14. package/out-tsc/src/list/ShortcutList.js.map +1 -1
  15. package/out-tsc/src/live/ContactChat.js +12 -321
  16. package/out-tsc/src/live/ContactChat.js.map +1 -1
  17. package/out-tsc/src/simulator/Simulator.js +428 -571
  18. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  19. package/out-tsc/test/temba-simulator.test.js +51 -32
  20. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  21. package/package.json +1 -1
  22. package/screenshots/truth/contacts/chat-failure.png +0 -0
  23. package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
  24. package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
  25. package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
  26. package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
  27. package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
  28. package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
  29. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  30. package/screenshots/truth/simulator/after-message-sent.png +0 -0
  31. package/screenshots/truth/simulator/after-reset.png +0 -0
  32. package/screenshots/truth/simulator/attachment-menu.png +0 -0
  33. package/screenshots/truth/simulator/context-expanded.png +0 -0
  34. package/screenshots/truth/simulator/context-explorer-open.png +0 -0
  35. package/screenshots/truth/simulator/event-info.png +0 -0
  36. package/screenshots/truth/simulator/image-attachment.png +0 -0
  37. package/screenshots/truth/simulator/open-initial.png +0 -0
  38. package/screenshots/truth/simulator/quick-replies.png +0 -0
  39. package/src/display/Chat.ts +123 -44
  40. package/src/events/eventRenderers.ts +527 -0
  41. package/src/flow/Editor.ts +3 -2
  42. package/src/flow/NodeEditor.ts +0 -1
  43. package/src/list/ShortcutList.ts +1 -1
  44. package/src/live/ContactChat.ts +17 -376
  45. package/src/simulator/Simulator.ts +487 -612
  46. package/test/temba-simulator.test.ts +64 -34
@@ -3,9 +3,11 @@ import { html } from 'lit-html';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import { css } from 'lit';
5
5
  import { property } from 'lit/decorators.js';
6
- import { postJSON, fromCookie } from '../utils';
6
+ import { postJSON, fromCookie, generateUUIDv7 } from '../utils';
7
7
  import { getStore } from '../store/Store';
8
8
  import { CustomEventType } from '../interfaces';
9
+ import { MessageType } from '../display/Chat';
10
+ import { Events, renderEvent } from '../events/eventRenderers';
9
11
  // test attachment URLs
10
12
  const TEST_IMAGES = [
11
13
  'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
@@ -28,8 +30,7 @@ const TEST_LOCATIONS = [
28
30
  const SIMULATOR_SIZES = {
29
31
  small: {
30
32
  phoneWidth: 270,
31
- phoneHeight: 576,
32
- phoneTotalHeight: 576,
33
+ phoneTotalHeight: 530,
33
34
  phoneScreenHeight: 376,
34
35
  contextWidth: 336,
35
36
  contextHeight: 416,
@@ -46,8 +47,7 @@ const SIMULATOR_SIZES = {
46
47
  },
47
48
  medium: {
48
49
  phoneWidth: 300,
49
- phoneHeight: 720,
50
- phoneTotalHeight: 720,
50
+ phoneTotalHeight: 600,
51
51
  phoneScreenHeight: 470,
52
52
  contextWidth: 420,
53
53
  contextHeight: 520,
@@ -64,8 +64,7 @@ const SIMULATOR_SIZES = {
64
64
  },
65
65
  large: {
66
66
  phoneWidth: 360,
67
- phoneHeight: 864,
68
- phoneTotalHeight: 864,
67
+ phoneTotalHeight: 700,
69
68
  phoneScreenHeight: 564,
70
69
  contextWidth: 504,
71
70
  contextHeight: 624,
@@ -89,6 +88,7 @@ export class Simulator extends RapidElement {
89
88
  this.animationTime = 200;
90
89
  this.events = [];
91
90
  this.previousEventCount = 0;
91
+ this.chat = null;
92
92
  this.session = null;
93
93
  this.context = null;
94
94
  this.contact = {
@@ -186,6 +186,7 @@ export class Simulator extends RapidElement {
186
186
 
187
187
  .phone-frame {
188
188
  width: var(--phone-width);
189
+ height: var(--phone-total-height);
189
190
  border-radius: 40px;
190
191
  border: 6px solid #1f2937;
191
192
  box-shadow: 0 0px 30px rgba(0, 0, 0, 0.4);
@@ -290,6 +291,39 @@ export class Simulator extends RapidElement {
290
291
  background: rgba(255, 255, 255, 0.5);
291
292
  }
292
293
 
294
+ /* Custom scrollbar for chat area to allow content to flow behind input */
295
+ .custom-scrollbar-container {
296
+ position: absolute;
297
+ top: 40px;
298
+ bottom: var(--bottom-input-height, 60px);
299
+ right: 4px;
300
+ width: 10px;
301
+ z-index: 20;
302
+ overflow-y: auto;
303
+ overflow-x: hidden;
304
+ }
305
+
306
+ .custom-scrollbar-container::-webkit-scrollbar {
307
+ width: 6px;
308
+ }
309
+
310
+ .custom-scrollbar-container::-webkit-scrollbar-track {
311
+ background: transparent;
312
+ }
313
+
314
+ .custom-scrollbar-container::-webkit-scrollbar-thumb {
315
+ background: rgba(0, 0, 0, 0.2);
316
+ border-radius: 3px;
317
+ }
318
+
319
+ .custom-scrollbar-container::-webkit-scrollbar-thumb:hover {
320
+ background: rgba(0, 0, 0, 0.4);
321
+ }
322
+
323
+ .custom-scrollbar-content {
324
+ width: 100%;
325
+ }
326
+
293
327
  .context-explorer.open {
294
328
  left: var(--context-offset);
295
329
  opacity: 1;
@@ -462,170 +496,95 @@ export class Simulator extends RapidElement {
462
496
  }
463
497
 
464
498
  .phone-screen {
499
+ position: absolute;
500
+ top: 0;
501
+ left: 0;
502
+ right: 0;
503
+ bottom: 0;
465
504
  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
505
  display: flex;
472
506
  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
507
  }
480
508
 
481
- .phone-screen::-webkit-scrollbar-track {
482
- background: transparent;
509
+ temba-chat {
510
+ flex: 1;
511
+ display: flex;
512
+ flex-direction: column;
513
+ min-height: 0;
514
+ --color-chat-in: #e5e5ea;
515
+ --color-chat-out: #007aff;
516
+ --chat-top-padding: calc(var(--cutout-height));
517
+ --chat-bottom-padding: calc(var(--bottom-input-height, 80px) - 10px);
483
518
  }
484
519
 
485
- .phone-screen::-webkit-scrollbar-thumb {
486
- background: rgba(0, 0, 0, 0.2);
487
- border-radius: 4px;
520
+ .bottom-input-container {
521
+ position: absolute;
522
+ bottom: 0px;
523
+ left: 0px;
524
+ right: 0px;
525
+ z-index: 10;
488
526
  }
489
527
 
490
- .phone-screen::-webkit-scrollbar-thumb:hover {
491
- background: rgba(0, 0, 0, 0.3);
528
+ .bottom-input-container::before {
529
+ content: '';
530
+ position: absolute;
531
+ top: 0;
532
+ left: 0;
533
+ right: 0;
534
+ bottom: 0;
535
+ background: rgba(255, 255, 255, 0.45);
536
+ backdrop-filter: blur(10px);
537
+ -webkit-mask-image: linear-gradient(to bottom, transparent, black 20px);
538
+ mask-image: linear-gradient(to bottom, transparent, black 20px);
539
+ z-index: -1;
492
540
  }
493
541
 
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
- }
542
+ .quick-replies-container {
543
+ display: flex;
544
+ flex-wrap: wrap;
545
+ justify-content: center;
546
+ gap: 6px;
547
+ z-index: 9;
507
548
  }
508
549
 
509
- .message {
510
- padding: 10px 14px;
511
- margin-bottom: 8px;
550
+ .quick-reply-btn {
551
+ padding: 4px 8px;
512
552
  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;
553
+ border: 1px solid var(--color-primary, #007aff);
573
554
  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;
555
+ color: var(--color-primary, #007aff);
592
556
  font-size: 11px;
593
- color: #8e8e93;
594
- margin: 4px 0;
595
- padding: 0 10px;
596
- line-height: 1.3;
557
+ cursor: pointer;
558
+ transition: all 0.2s ease;
559
+ flex-shrink: 0;
597
560
  }
598
- .event-info.animated {
599
- animation: messageAppear var(--animation-time) ease-out forwards;
600
- opacity: 0;
561
+
562
+ .quick-reply-btn:hover:not(:disabled) {
563
+ background: var(--color-primary, #007aff);
564
+ color: white;
565
+ }
566
+
567
+ .quick-reply-btn:disabled {
568
+ opacity: 0.5;
569
+ cursor: not-allowed;
601
570
  }
571
+
602
572
  .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
573
  padding: 8px 12px;
610
574
  border-top: none;
611
575
  display: flex;
612
576
  align-items: center;
613
577
  gap: 8px;
614
- position: absolute;
615
- bottom: 0px;
616
- left: 0px;
617
- right: 0px;
618
578
  z-index: 10;
619
579
  }
620
580
  .message-input input {
621
581
  flex: 1;
622
- border: 1px solid #c6c6c8;
582
+ border: 1px solid #c6c6c857;
623
583
  border-radius: 20px;
624
584
  padding: 8px 15px;
625
585
  font-size: 15px;
626
586
  margin-bottom: 5px;
627
- background: white;
628
- border: none;
587
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
629
588
  outline: none;
630
589
  }
631
590
  .message-input input::placeholder {
@@ -636,7 +595,7 @@ export class Simulator extends RapidElement {
636
595
  height: 30px;
637
596
  border-radius: 50%;
638
597
  background: #fff;
639
- border: none;
598
+ border: 1px solid #c6c6c857;
640
599
  display: flex;
641
600
  align-items: center;
642
601
  justify-content: center;
@@ -644,6 +603,7 @@ export class Simulator extends RapidElement {
644
603
  flex-shrink: 0;
645
604
  margin-bottom: 5px;
646
605
  transition: all var(--animation-time) ease;
606
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
647
607
  color: #000;
648
608
  }
649
609
  .attachment-button:hover {
@@ -693,37 +653,6 @@ export class Simulator extends RapidElement {
693
653
  .attachment-menu-item temba-icon {
694
654
  color: #007aff;
695
655
  }
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
656
  `;
728
657
  }
729
658
  // method to reset attachment indices for testing
@@ -752,8 +681,95 @@ export class Simulator extends RapidElement {
752
681
  const config = this.sizeConfig;
753
682
  return config.contextWidth + config.contextOffset - config.phoneWidth;
754
683
  }
684
+ connectedCallback() {
685
+ super.connectedCallback();
686
+ }
687
+ firstUpdated(changes) {
688
+ super.firstUpdated(changes);
689
+ this.chat = this.shadowRoot.querySelector('temba-chat');
690
+ // if we have events that were collected before chat was ready, add them now
691
+ if (this.chat && this.events.length > 0) {
692
+ this.chat.addMessages(this.events, null, true);
693
+ }
694
+ this.setupCustomScrollbar();
695
+ }
696
+ setupCustomScrollbar() {
697
+ var _a, _b, _c;
698
+ const chat = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('temba-chat');
699
+ const scrollContainer = (_b = this.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('.custom-scrollbar-container');
700
+ const scrollContent = (_c = this.shadowRoot) === null || _c === void 0 ? void 0 : _c.querySelector('.custom-scrollbar-content');
701
+ if (!chat || !scrollContainer || !scrollContent)
702
+ return;
703
+ chat.updateComplete.then(() => {
704
+ var _a;
705
+ const chatScroll = (_a = chat.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.scroll');
706
+ if (!chatScroll)
707
+ return;
708
+ let ignoreScroll = false;
709
+ // Sync from chat to custom scrollbar
710
+ chatScroll.addEventListener('scroll', () => {
711
+ if (!ignoreScroll) {
712
+ ignoreScroll = true;
713
+ // Chat: 0 (bottom) ... -Max (top) (Negative scrolling)
714
+ // Custom: Max (bottom) ... 0 (top) (Positive scrolling)
715
+ const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
716
+ // Math.abs to handle negative scrollTop
717
+ const distanceFromBottom = Math.abs(chatScroll.scrollTop);
718
+ const newCustomScrollTop = maxScroll - distanceFromBottom;
719
+ scrollContainer.scrollTop = newCustomScrollTop;
720
+ requestAnimationFrame(() => (ignoreScroll = false));
721
+ }
722
+ });
723
+ // Sync from custom scrollbar to chat
724
+ scrollContainer.addEventListener('scroll', () => {
725
+ if (!ignoreScroll) {
726
+ ignoreScroll = true;
727
+ const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
728
+ const distanceFromBottom = maxScroll - scrollContainer.scrollTop;
729
+ // chat scrollTop should be -distanceFromBottom
730
+ chatScroll.scrollTop = -distanceFromBottom;
731
+ requestAnimationFrame(() => (ignoreScroll = false));
732
+ }
733
+ });
734
+ // Sync height
735
+ const syncHeight = () => {
736
+ const chatMaxScroll = chatScroll.scrollHeight - chatScroll.clientHeight;
737
+ const customClientHeight = scrollContainer.clientHeight;
738
+ // ensure minimum height
739
+ if (chatMaxScroll <= 0) {
740
+ scrollContent.style.height = '100%';
741
+ return;
742
+ }
743
+ const newHeight = chatMaxScroll + customClientHeight;
744
+ scrollContent.style.height = `${newHeight}px`;
745
+ // If we were effectively at the bottom, stay at the bottom
746
+ // This is a heuristic, assuming if we're close enough we're "at bottom"
747
+ // But the Chat component handles scrollToBottom on new messages, which fires scroll event,
748
+ // which updates us. So we might not need to force it here unless resize happens without message.
749
+ if (Math.abs(chatScroll.scrollTop) < 5) {
750
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
751
+ }
752
+ };
753
+ // Observe changes
754
+ const observer = new MutationObserver(syncHeight);
755
+ observer.observe(chatScroll, {
756
+ childList: true,
757
+ subtree: true,
758
+ attributes: true
759
+ });
760
+ const resizeObserver = new ResizeObserver(syncHeight);
761
+ resizeObserver.observe(chatScroll);
762
+ // Initial sync
763
+ syncHeight();
764
+ });
765
+ }
755
766
  updated(changes) {
756
767
  super.updated(changes);
768
+ if (changes.has('currentQuickReplies') ||
769
+ changes.has('keyboardVisible') ||
770
+ changes.has('attachmentMenuOpen')) {
771
+ this.updateBottomInputHeight();
772
+ }
757
773
  if (changes.has('flow') && this.flow) {
758
774
  this.endpoint = `/flow/simulate/${this.flow}/`;
759
775
  }
@@ -834,8 +850,12 @@ export class Simulator extends RapidElement {
834
850
  phoneWindow.show();
835
851
  this.isVisible = true;
836
852
  getStore().getState().setSimulatorActive(true);
853
+ // ensure chat component is available
854
+ if (!this.chat) {
855
+ this.chat = this.shadowRoot.querySelector('temba-chat');
856
+ }
837
857
  // start the simulation if we haven't already
838
- if (this.events.length === 0) {
858
+ if (!this.session) {
839
859
  this.startFlow();
840
860
  }
841
861
  }
@@ -858,20 +878,37 @@ export class Simulator extends RapidElement {
858
878
  }
859
879
  catch (error) {
860
880
  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'
881
+ const errorEvent = {
882
+ uuid: generateUUIDv7(),
883
+ type: 'error',
884
+ created_on: new Date(now),
885
+ _rendered: {
886
+ html: html `<p>Failed to start simulation</p>`,
887
+ type: MessageType.Error
867
888
  }
868
- ];
889
+ };
890
+ if (this.chat) {
891
+ this.chat.addMessages([errorEvent], null, true);
892
+ }
893
+ else {
894
+ this.events = [...this.events, errorEvent];
895
+ }
869
896
  }
870
897
  }
871
898
  updateRunContext(runContext, msgInEvt) {
872
899
  var _a;
900
+ const newEvents = [];
901
+ // add the user's message if provided
873
902
  if (msgInEvt) {
874
- this.events = [...this.events, msgInEvt];
903
+ // ensure it has a UUID
904
+ if (!msgInEvt.uuid) {
905
+ msgInEvt.uuid = generateUUIDv7();
906
+ }
907
+ // ensure created_on is a Date object
908
+ if (typeof msgInEvt.created_on === 'string') {
909
+ msgInEvt.created_on = new Date(msgInEvt.created_on);
910
+ }
911
+ newEvents.push(msgInEvt);
875
912
  }
876
913
  if (runContext.session) {
877
914
  this.session = runContext.session;
@@ -884,18 +921,60 @@ export class Simulator extends RapidElement {
884
921
  if (runContext.context) {
885
922
  this.context = runContext.context;
886
923
  }
924
+ // extract quick replies from the most recent sprint
925
+ this.currentQuickReplies = [];
887
926
  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) {
927
+ for (const rawEvent of runContext.events) {
928
+ // skip msg_received events from the server since we already added the user's message
929
+ if (rawEvent.type === 'msg_received') {
930
+ continue;
931
+ }
932
+ // skip msg_created events without a proper msg property
933
+ if (rawEvent.type === 'msg_created' && !rawEvent.msg) {
934
+ continue;
935
+ }
936
+ // convert to ContactEvent
937
+ const event = {
938
+ ...rawEvent,
939
+ uuid: rawEvent.uuid || generateUUIDv7(),
940
+ created_on: typeof rawEvent.created_on === 'string'
941
+ ? new Date(rawEvent.created_on)
942
+ : rawEvent.created_on
943
+ };
944
+ // pre-render non-message events
945
+ this.prerenderEvent(event);
946
+ // extract quick replies from msg_created events
892
947
  if (event.type === 'msg_created' && ((_a = event.msg) === null || _a === void 0 ? void 0 : _a.quick_replies)) {
893
948
  this.currentQuickReplies = event.msg.quick_replies;
894
949
  }
950
+ const isMessage = event.type === 'msg_created';
951
+ const msg = event.msg;
952
+ // Check if the event should be displayed.
953
+ // 1. If it's a message, it must have text or attachments
954
+ if (isMessage) {
955
+ const hasText = msg.text && msg.text.trim().length > 0;
956
+ const hasAttachments = msg.attachments && msg.attachments.length > 0;
957
+ if (!hasText && !hasAttachments) {
958
+ continue;
959
+ }
960
+ }
961
+ // 2. If it's not a message, it must have been rendered by prerenderEvent
962
+ else if (!event._rendered) {
963
+ continue;
964
+ }
965
+ newEvents.push(event);
895
966
  }
896
967
  }
968
+ // add all new events to chat component if it exists
969
+ if (this.chat) {
970
+ this.chat.addMessages(newEvents, null, true);
971
+ }
972
+ else {
973
+ // fallback: store events and add them once chat is ready
974
+ this.events = [...this.events, ...newEvents];
975
+ }
897
976
  this.sprinting = false;
898
- this.requestUpdate();
977
+ this.requestUpdate(); // trigger re-render for quick replies
899
978
  this.scrollToBottom();
900
979
  this.updateActivity();
901
980
  }
@@ -960,6 +1039,18 @@ export class Simulator extends RapidElement {
960
1039
  }
961
1040
  }
962
1041
  scrollToBottom() {
1042
+ if (this.chat) {
1043
+ // chat component handles scrolling, but we still need to focus input
1044
+ this.chat.scrollToBottom();
1045
+ setTimeout(() => {
1046
+ var _a;
1047
+ const input = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.message-input input');
1048
+ if (input) {
1049
+ input.focus();
1050
+ }
1051
+ }, 50);
1052
+ return;
1053
+ }
963
1054
  // wait for render, then scroll to bottom
964
1055
  setTimeout(() => {
965
1056
  var _a, _b;
@@ -976,6 +1067,37 @@ export class Simulator extends RapidElement {
976
1067
  }
977
1068
  }, 50);
978
1069
  }
1070
+ prerenderEvent(event) {
1071
+ // skip if already rendered or is a message event
1072
+ if (event._rendered ||
1073
+ event.type === Events.MSG_CREATED ||
1074
+ event.type === Events.MSG_RECEIVED) {
1075
+ return;
1076
+ }
1077
+ // handle simulator-specific events (errors, warnings, failures)
1078
+ if (event.type === 'error' || event.type === 'failure') {
1079
+ event._rendered = {
1080
+ html: renderEvent(event, true),
1081
+ type: MessageType.Error
1082
+ };
1083
+ return;
1084
+ }
1085
+ if (event.type === 'warning') {
1086
+ event._rendered = {
1087
+ html: renderEvent(event, true),
1088
+ type: MessageType.Note
1089
+ };
1090
+ return;
1091
+ }
1092
+ // try to render as a standard event
1093
+ const rendered = renderEvent(event, true);
1094
+ if (rendered) {
1095
+ event._rendered = {
1096
+ html: rendered,
1097
+ type: MessageType.Inline
1098
+ };
1099
+ }
1100
+ }
979
1101
  handleClose() {
980
1102
  const phoneWindow = this.shadowRoot.getElementById('phone-window');
981
1103
  // phoneWindow.hide();
@@ -992,6 +1114,10 @@ export class Simulator extends RapidElement {
992
1114
  this.sprinting = false;
993
1115
  this.previousEventCount = 0;
994
1116
  this.currentQuickReplies = [];
1117
+ // reset chat component
1118
+ if (this.chat) {
1119
+ this.chat.reset();
1120
+ }
995
1121
  // Clear simulator activity data
996
1122
  getStore().getState().updateSimulatorActivity({
997
1123
  segments: {},
@@ -1179,19 +1305,34 @@ export class Simulator extends RapidElement {
1179
1305
  this.currentQuickReplies = [];
1180
1306
  this.attachmentMenuOpen = false;
1181
1307
  const now = new Date().toISOString();
1182
- const msgInEvt = {
1183
- uuid: crypto.randomUUID(),
1308
+ // create the event for the API (with ISO string date)
1309
+ const msgInEvtForAPI = {
1310
+ uuid: generateUUIDv7(),
1184
1311
  type: 'msg_received',
1185
1312
  created_on: now,
1186
1313
  msg: {
1187
- uuid: crypto.randomUUID(),
1314
+ uuid: generateUUIDv7(),
1188
1315
  text: text || '',
1189
1316
  urn: this.contact.urns[0],
1190
- attachments: attachment ? [attachment] : []
1317
+ direction: 'in',
1318
+ type: 'text',
1319
+ attachments: attachment ? [attachment] : [],
1320
+ quick_replies: [],
1321
+ channel: { uuid: generateUUIDv7(), name: 'Simulator' }
1191
1322
  }
1192
1323
  };
1193
- // show user's message immediately
1194
- this.events = [...this.events, msgInEvt];
1324
+ // create the ContactEvent for display (with Date object)
1325
+ const msgInEvt = {
1326
+ ...msgInEvtForAPI,
1327
+ created_on: new Date(now)
1328
+ };
1329
+ // show user's message immediately via chat component
1330
+ if (this.chat) {
1331
+ this.chat.addMessages([msgInEvt], null, true);
1332
+ }
1333
+ else {
1334
+ this.events = [...this.events, msgInEvt];
1335
+ }
1195
1336
  this.requestUpdate();
1196
1337
  this.scrollToBottom();
1197
1338
  const body = {
@@ -1199,7 +1340,7 @@ export class Simulator extends RapidElement {
1199
1340
  contact: this.contact,
1200
1341
  resume: {
1201
1342
  type: 'msg',
1202
- event: msgInEvt,
1343
+ event: msgInEvtForAPI,
1203
1344
  resumed_on: now
1204
1345
  }
1205
1346
  };
@@ -1212,14 +1353,21 @@ export class Simulator extends RapidElement {
1212
1353
  }
1213
1354
  catch (error) {
1214
1355
  console.error('Failed to resume simulation:', error);
1215
- this.events = [
1216
- ...this.events,
1217
- {
1218
- type: 'error',
1219
- created_on: now,
1220
- text: 'Failed to send message'
1356
+ const errorEvent = {
1357
+ uuid: generateUUIDv7(),
1358
+ type: 'error',
1359
+ created_on: new Date(now),
1360
+ _rendered: {
1361
+ html: html `<p>Failed to send message</p>`,
1362
+ type: MessageType.Error
1221
1363
  }
1222
- ];
1364
+ };
1365
+ if (this.chat) {
1366
+ this.chat.addMessages([errorEvent], null, true);
1367
+ }
1368
+ else {
1369
+ this.events = [...this.events, errorEvent];
1370
+ }
1223
1371
  this.sprinting = false;
1224
1372
  }
1225
1373
  }
@@ -1236,9 +1384,9 @@ export class Simulator extends RapidElement {
1236
1384
  const input = evt.target;
1237
1385
  this.inputValue = input.value;
1238
1386
  }
1239
- handleQuickReply(quickReply) {
1240
- if (!this.sprinting) {
1241
- this.resume(quickReply);
1387
+ handleQuickReplyClick(text) {
1388
+ if (!this.sprinting && text) {
1389
+ this.resume(text);
1242
1390
  }
1243
1391
  }
1244
1392
  handleToggleAttachmentMenu() {
@@ -1285,330 +1433,15 @@ export class Simulator extends RapidElement {
1285
1433
  this.resume('', attachment);
1286
1434
  }
1287
1435
  }
1288
- getEventDescription(event) {
1289
- switch (event.type) {
1290
- case 'contact_groups_changed': {
1291
- const groups = event.groups_added || [];
1292
- const removedGroups = event.groups_removed || [];
1293
- if (groups.length > 0) {
1294
- const groupNames = groups.map((g) => `"${g.name}"`).join(', ');
1295
- return `Added to ${groupNames}`;
1296
- }
1297
- if (removedGroups.length > 0) {
1298
- const groupNames = removedGroups
1299
- .map((g) => `"${g.name}"`)
1300
- .join(', ');
1301
- return `Removed from ${groupNames}`;
1302
- }
1303
- break;
1304
- }
1305
- case 'contact_field_changed': {
1306
- const field = event.field;
1307
- const value = event.value;
1308
- const valueText = value ? value.text || value : '';
1309
- if (field) {
1310
- if (valueText) {
1311
- return `Set contact "${field.name}" to "${valueText}"`;
1312
- }
1313
- else {
1314
- return `Cleared contact "${field.name}"`;
1315
- }
1316
- }
1317
- break;
1318
- }
1319
- case 'contact_language_changed':
1320
- return `Set preferred language to "${event.language}"`;
1321
- case 'contact_name_changed':
1322
- return `Set contact name to "${event.name}"`;
1323
- case 'contact_status_changed':
1324
- return `Set status to "${event.status}"`;
1325
- case 'contact_urns_changed':
1326
- return `Added a URN for the contact`;
1327
- case 'input_labels_added': {
1328
- const labels = event.labels || [];
1329
- if (labels.length > 0) {
1330
- const labelNames = labels.map((l) => `"${l.name}"`).join(', ');
1331
- return `Message labeled with ${labelNames}`;
1332
- }
1333
- break;
1334
- }
1335
- case 'run_result_changed':
1336
- return `Set result "${event.name}" to "${event.value}"`;
1337
- case 'run_started':
1338
- case 'flow_entered': {
1339
- const flow = event.flow;
1340
- if (flow) {
1341
- return `Entered flow "${flow.name}"`;
1342
- }
1343
- break;
1344
- }
1345
- case 'run_ended': {
1346
- const flow = event.flow;
1347
- if (flow) {
1348
- return `Exited flow "${flow.name}"`;
1349
- }
1350
- break;
1351
- }
1352
- case 'email_created':
1353
- case 'email_sent': {
1354
- const recipients = event.to || event.addresses || [];
1355
- const subject = event.subject;
1356
- const recipientList = recipients
1357
- .map((r) => `"${r}"`)
1358
- .join(', ');
1359
- return `Sent email to ${recipientList} with subject "${subject}"`;
1360
- }
1361
- case 'broadcast_created': {
1362
- const translations = event.translations;
1363
- const baseLanguage = event.base_language;
1364
- if (translations && translations[baseLanguage]) {
1365
- return `Sent broadcast: "${translations[baseLanguage].text}"`;
1366
- }
1367
- return `Sent broadcast`;
1368
- }
1369
- case 'session_triggered': {
1370
- const flow = event.flow;
1371
- if (flow) {
1372
- return `Started somebody else in "${flow.name}"`;
1373
- }
1374
- break;
1375
- }
1376
- case 'ticket_opened': {
1377
- const ticket = event.ticket;
1378
- if (ticket && ticket.topic) {
1379
- return `Ticket opened with topic "${ticket.topic.name}"`;
1380
- }
1381
- return `Ticket opened`;
1382
- }
1383
- case 'resthook_called':
1384
- return `Triggered flow event "${event.resthook}"`;
1385
- case 'webhook_called':
1386
- return `Called ${event.url}`;
1387
- case 'service_called': {
1388
- const service = event.service;
1389
- if (service === 'classifier') {
1390
- return `Called classifier`;
1391
- }
1392
- return `Called ${service}`;
1393
- }
1394
- case 'airtime_transferred': {
1395
- const amount = event.actual_amount;
1396
- const currency = event.currency;
1397
- const recipient = event.recipient;
1398
- if (amount && currency && recipient) {
1399
- return `Transferred ${amount} ${currency} to ${recipient}`;
1400
- }
1401
- break;
1402
- }
1403
- case 'info':
1404
- return event.text;
1405
- case 'warning':
1406
- return `⚠️ ${event.text}`;
1407
- }
1408
- return null;
1409
- }
1410
- renderAlertMessage(type, text, animatedClass, animationDelay) {
1411
- const config = {
1412
- error: {
1413
- icon: '❗',
1414
- bgColor: '#fee2e2',
1415
- textColor: '#991b1b',
1416
- defaultText: 'An error occurred'
1417
- },
1418
- warning: {
1419
- icon: '⚠️',
1420
- bgColor: '#fef3c7',
1421
- textColor: 'rgba(125, 87, 18, 0.8)',
1422
- defaultText: 'A warning occurred'
1423
- },
1424
- failure: {
1425
- icon: '💥',
1426
- bgColor: '#fee2e2',
1427
- textColor: '#991b1b',
1428
- defaultText: 'A failure occurred'
1436
+ updateBottomInputHeight() {
1437
+ requestAnimationFrame(() => {
1438
+ var _a;
1439
+ const bottomContainer = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('.bottom-input-container');
1440
+ if (bottomContainer) {
1441
+ const height = bottomContainer.offsetHeight;
1442
+ this.style.setProperty('--bottom-input-height', `${height}px`);
1429
1443
  }
1430
- }[type];
1431
- return html `
1432
- <div
1433
- class="event-info ${animatedClass}"
1434
- style="display:flex; align-items:center; background: ${config.bgColor}; color: ${config.textColor}; padding: 6px; margin: 4px 12px; border-radius: 8px; animation-delay: ${animationDelay}"
1435
- >
1436
- <div style="padding:4px;margin-right:6px;font-size:15px">
1437
- ${config.icon}
1438
- </div>
1439
- <div style="padding-right:2px;text-align:left">
1440
- ${text || config.defaultText}
1441
- </div>
1442
- </div>
1443
- `;
1444
- }
1445
- renderAttachment(attachment) {
1446
- // parse attachment format: "type/subtype:url" or "geo:lat,long"
1447
- const parts = attachment.split(':');
1448
- const type = parts[0];
1449
- const content = parts.slice(1).join(':'); // rejoin in case url has colons
1450
- if (type === 'geo') {
1451
- // use temba-thumbnail for location to get map image
1452
- return html `
1453
- <div class="attachment-location">
1454
- <temba-thumbnail attachment="${attachment}"></temba-thumbnail>
1455
- </div>
1456
- `;
1457
- }
1458
- else if (type.startsWith('image/')) {
1459
- // custom image rendering
1460
- return html `
1461
- <div class="attachment">
1462
- <img src="${content}" alt="Image attachment" />
1463
- </div>
1464
- `;
1465
- }
1466
- else if (type.startsWith('video/')) {
1467
- // custom video rendering
1468
- return html `
1469
- <div class="attachment">
1470
- <video controls>
1471
- <source src="${content}" type="${type}" />
1472
- </video>
1473
- </div>
1474
- `;
1475
- }
1476
- else if (type.startsWith('audio/')) {
1477
- // custom audio rendering
1478
- return html `
1479
- <div class="attachment">
1480
- <div class="attachment-audio">
1481
- <audio controls>
1482
- <source src="${content}" type="${type}" />
1483
- </audio>
1484
- </div>
1485
- </div>
1486
- `;
1487
- }
1488
- // fallback for unknown types
1489
- return html `
1490
- <div class="attachment">
1491
- <span>Attachment</span>
1492
- </div>
1493
- `;
1494
- }
1495
- renderMessages() {
1496
- if (this.events.length === 0) {
1497
- return html `
1498
- <div class="message incoming">👋 Welcome! Starting simulation...</div>
1499
- `;
1500
- }
1501
- const eventTemplates = this.events.map((event, index) => {
1502
- // only animate messages that are new (beyond previous count)
1503
- const isNew = index >= this.previousEventCount;
1504
- const animatedClass = isNew ? 'animated' : '';
1505
- // stagger animations for new messages
1506
- const animationDelay = isNew
1507
- ? `${(index - this.previousEventCount) * 0.2}s`
1508
- : '0s';
1509
- if (event.type === 'msg_received' && event.msg) {
1510
- const hasAttachments = event.msg.attachments && event.msg.attachments.length > 0;
1511
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1512
- return html `
1513
- ${hasAttachments
1514
- ? html `
1515
- <div
1516
- class="attachment-wrapper outgoing ${animatedClass}"
1517
- style="animation-delay: ${animationDelay}"
1518
- >
1519
- ${event.msg.attachments.map((att) => this.renderAttachment(att))}
1520
- </div>
1521
- `
1522
- : html ``}
1523
- ${hasText
1524
- ? html `
1525
- <div
1526
- class="message outgoing ${animatedClass}"
1527
- style="animation-delay: ${animationDelay}"
1528
- >
1529
- ${event.msg.text}
1530
- </div>
1531
- `
1532
- : html ``}
1533
- `;
1534
- }
1535
- else if (event.type === 'msg_created' && event.msg) {
1536
- const hasAttachments = event.msg.attachments && event.msg.attachments.length > 0;
1537
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1538
- return html `
1539
- ${hasAttachments
1540
- ? html `
1541
- <div
1542
- class="attachment-wrapper incoming ${animatedClass}"
1543
- style="animation-delay: ${animationDelay}"
1544
- >
1545
- ${event.msg.attachments.map((att) => this.renderAttachment(att))}
1546
- </div>
1547
- `
1548
- : html ``}
1549
- ${hasText
1550
- ? html `
1551
- <div
1552
- class="message incoming ${animatedClass}"
1553
- style="animation-delay: ${animationDelay}"
1554
- >
1555
- ${event.msg.text}
1556
- </div>
1557
- `
1558
- : html ``}
1559
- `;
1560
- }
1561
- else if (event.type === 'failure') {
1562
- return this.renderAlertMessage('failure', event.text, animatedClass, animationDelay);
1563
- }
1564
- else if (event.type === 'warning') {
1565
- return this.renderAlertMessage('warning', event.text, animatedClass, animationDelay);
1566
- }
1567
- else if (event.type === 'error') {
1568
- return this.renderAlertMessage('error', event.text, animatedClass, animationDelay);
1569
- }
1570
- else {
1571
- // check if this is an event we should display
1572
- const description = this.getEventDescription(event);
1573
- if (description) {
1574
- return html `
1575
- <div
1576
- class="event-info ${animatedClass}"
1577
- style="animation-delay: ${animationDelay}"
1578
- >
1579
- ${description}
1580
- </div>
1581
- `;
1582
- }
1583
- }
1584
- return html ``;
1585
1444
  });
1586
- // render quick replies at the end if we have any from the most recent sprint
1587
- const hasQuickReplies = this.currentQuickReplies.length > 0;
1588
- const quickRepliesAnimationDelay = this.events.length >= this.previousEventCount
1589
- ? `${(this.events.length - this.previousEventCount) * 0.2}s`
1590
- : '0s';
1591
- return html `
1592
- ${eventTemplates}
1593
- ${hasQuickReplies
1594
- ? html `
1595
- <div
1596
- class="quick-replies animated"
1597
- style="animation-delay: ${quickRepliesAnimationDelay}"
1598
- >
1599
- ${this.currentQuickReplies.map((qr) => html `
1600
- <button
1601
- class="quick-reply-btn animated"
1602
- style="animation-delay: ${quickRepliesAnimationDelay}"
1603
- @click=${() => this.handleQuickReply(qr.text)}
1604
- >
1605
- ${qr.text}
1606
- </button>
1607
- `)}
1608
- </div>
1609
- `
1610
- : html ``}
1611
- `;
1612
1445
  }
1613
1446
  render() {
1614
1447
  const config = this.sizeConfig;
@@ -1630,9 +1463,11 @@ export class Simulator extends RapidElement {
1630
1463
  --cutout-island-width: ${config.cutoutIslandWidth}px;
1631
1464
  --cutout-island-height: ${config.cutoutIslandHeight}px;
1632
1465
  --cutout-island-top: ${config.cutoutIslandTop}px;
1466
+ --animation-time: ${this.animationTime}ms;
1633
1467
  `;
1634
1468
  return html `
1635
1469
  <temba-floating-window
1470
+ style="--transition-duration: ${this.animationTime}ms"
1636
1471
  id="phone-window"
1637
1472
  width="${this.windowWidth}"
1638
1473
  leftBoundaryMargin="${this.leftBoundaryMargin}"
@@ -1697,56 +1532,78 @@ export class Simulator extends RapidElement {
1697
1532
  <div class="dynamic-island"></div>
1698
1533
  </div>
1699
1534
  </div>
1700
- <div class="phone-screen">${this.renderMessages()}</div>
1701
- <div class="message-input">
1702
- <button
1703
- class="attachment-button"
1704
- @click=${this.handleToggleAttachmentMenu}
1705
- ?disabled=${this.sprinting}
1706
- >
1707
- <temba-icon name="plus" size="1.5"></temba-icon>
1708
- </button>
1709
- <input
1710
- type="text"
1711
- placeholder="Enter Message"
1712
- .value=${this.inputValue}
1713
- @input=${this.handleInput}
1714
- @keyup=${this.handleKeyUp}
1715
- ?disabled=${this.sprinting}
1716
- />
1717
- <div
1718
- class="attachment-menu ${this.attachmentMenuOpen ? 'open' : ''}"
1719
- >
1720
- <div
1721
- class="attachment-menu-item"
1722
- @click=${() => this.handleSendAttachment('image')}
1723
- >
1724
- <temba-icon name="attachment_image" size="1.2"></temba-icon>
1725
- <span>Image</span>
1726
- </div>
1727
- <div
1728
- class="attachment-menu-item"
1729
- @click=${() => this.handleSendAttachment('video')}
1730
- >
1731
- <temba-icon name="attachment_video" size="1.2"></temba-icon>
1732
- <span>Video</span>
1733
- </div>
1734
- <div
1735
- class="attachment-menu-item"
1736
- @click=${() => this.handleSendAttachment('audio')}
1535
+ <temba-chat class="phone-screen" .showTimestamps=${false}>
1536
+ </temba-chat>
1537
+ <div class="custom-scrollbar-container">
1538
+ <div class="custom-scrollbar-content"></div>
1539
+ </div>
1540
+
1541
+ <div class="bottom-input-container">
1542
+ ${this.currentQuickReplies.length > 0
1543
+ ? html `<div class="quick-replies-container">
1544
+ ${this.currentQuickReplies.map((qr) => html `
1545
+ <button
1546
+ class="quick-reply-btn"
1547
+ @click=${() => this.handleQuickReplyClick(qr.text)}
1548
+ ?disabled=${this.sprinting}
1549
+ >
1550
+ ${qr.text}
1551
+ </button>
1552
+ `)}
1553
+ </div>`
1554
+ : null}
1555
+ <div class="message-input">
1556
+ <button
1557
+ class="attachment-button"
1558
+ @click=${this.handleToggleAttachmentMenu}
1559
+ ?disabled=${this.sprinting}
1737
1560
  >
1738
- <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1739
- <span>Audio</span>
1740
- </div>
1561
+ <temba-icon name="plus" size="1.5"></temba-icon>
1562
+ </button>
1563
+ <input
1564
+ type="text"
1565
+ placeholder="Enter Message"
1566
+ .value=${this.inputValue}
1567
+ @input=${this.handleInput}
1568
+ @keyup=${this.handleKeyUp}
1569
+ ?disabled=${this.sprinting}
1570
+ />
1741
1571
  <div
1742
- class="attachment-menu-item"
1743
- @click=${() => this.handleSendAttachment('location')}
1572
+ class="attachment-menu ${this.attachmentMenuOpen
1573
+ ? 'open'
1574
+ : ''}"
1744
1575
  >
1745
- <temba-icon
1746
- name="attachment_location"
1747
- size="1.2"
1748
- ></temba-icon>
1749
- <span>Location</span>
1576
+ <div
1577
+ class="attachment-menu-item"
1578
+ @click=${() => this.handleSendAttachment('image')}
1579
+ >
1580
+ <temba-icon name="attachment_image" size="1.2"></temba-icon>
1581
+ <span>Image</span>
1582
+ </div>
1583
+ <div
1584
+ class="attachment-menu-item"
1585
+ @click=${() => this.handleSendAttachment('video')}
1586
+ >
1587
+ <temba-icon name="attachment_video" size="1.2"></temba-icon>
1588
+ <span>Video</span>
1589
+ </div>
1590
+ <div
1591
+ class="attachment-menu-item"
1592
+ @click=${() => this.handleSendAttachment('audio')}
1593
+ >
1594
+ <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1595
+ <span>Audio</span>
1596
+ </div>
1597
+ <div
1598
+ class="attachment-menu-item"
1599
+ @click=${() => this.handleSendAttachment('location')}
1600
+ >
1601
+ <temba-icon
1602
+ name="attachment_location"
1603
+ size="1.2"
1604
+ ></temba-icon>
1605
+ <span>Location</span>
1606
+ </div>
1750
1607
  </div>
1751
1608
  </div>
1752
1609
  </div>