@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 { RapidElement } from '../RapidElement';
3
3
  import { FloatingWindow } from '../layout/FloatingWindow';
4
4
  import { css, PropertyValueMap } 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 { Chat, ContactEvent, MessageType } from '../display/Chat';
10
+ import { Events, renderEvent } from '../events/eventRenderers';
9
11
 
10
12
  // test attachment URLs
11
13
  const TEST_IMAGES = [
@@ -73,7 +75,6 @@ interface RunContext {
73
75
 
74
76
  interface SimulatorSize {
75
77
  phoneWidth: number;
76
- phoneHeight: number;
77
78
  phoneTotalHeight: number;
78
79
  phoneScreenHeight: number;
79
80
  contextWidth: number;
@@ -93,8 +94,7 @@ interface SimulatorSize {
93
94
  const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
94
95
  small: {
95
96
  phoneWidth: 270,
96
- phoneHeight: 576,
97
- phoneTotalHeight: 576,
97
+ phoneTotalHeight: 530,
98
98
  phoneScreenHeight: 376,
99
99
  contextWidth: 336,
100
100
  contextHeight: 416,
@@ -111,8 +111,7 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
111
111
  },
112
112
  medium: {
113
113
  phoneWidth: 300,
114
- phoneHeight: 720,
115
- phoneTotalHeight: 720,
114
+ phoneTotalHeight: 600,
116
115
  phoneScreenHeight: 470,
117
116
  contextWidth: 420,
118
117
  contextHeight: 520,
@@ -129,8 +128,7 @@ const SIMULATOR_SIZES: Record<string, SimulatorSize> = {
129
128
  },
130
129
  large: {
131
130
  phoneWidth: 360,
132
- phoneHeight: 864,
133
- phoneTotalHeight: 864,
131
+ phoneTotalHeight: 700,
134
132
  phoneScreenHeight: 564,
135
133
  contextWidth: 504,
136
134
  contextHeight: 624,
@@ -217,6 +215,7 @@ export class Simulator extends RapidElement {
217
215
 
218
216
  .phone-frame {
219
217
  width: var(--phone-width);
218
+ height: var(--phone-total-height);
220
219
  border-radius: 40px;
221
220
  border: 6px solid #1f2937;
222
221
  box-shadow: 0 0px 30px rgba(0, 0, 0, 0.4);
@@ -321,6 +320,39 @@ export class Simulator extends RapidElement {
321
320
  background: rgba(255, 255, 255, 0.5);
322
321
  }
323
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
+
324
356
  .context-explorer.open {
325
357
  left: var(--context-offset);
326
358
  opacity: 1;
@@ -493,170 +525,95 @@ export class Simulator extends RapidElement {
493
525
  }
494
526
 
495
527
  .phone-screen {
528
+ position: absolute;
529
+ top: 0;
530
+ left: 0;
531
+ right: 0;
532
+ bottom: 0;
496
533
  background: white;
497
- padding: 15px;
498
- padding-top: calc(var(--cutout-height) + 10px);
499
- padding-bottom: 60px;
500
- height: var(--phone-screen-height);
501
- overflow-y: scroll;
502
534
  display: flex;
503
535
  flex-direction: column;
504
- scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
505
- scrollbar-width: thin;
506
- }
507
-
508
- .phone-screen::-webkit-scrollbar {
509
- width: 8px;
510
536
  }
511
537
 
512
- .phone-screen::-webkit-scrollbar-track {
513
- 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);
514
547
  }
515
548
 
516
- .phone-screen::-webkit-scrollbar-thumb {
517
- background: rgba(0, 0, 0, 0.2);
518
- border-radius: 4px;
549
+ .bottom-input-container {
550
+ position: absolute;
551
+ bottom: 0px;
552
+ left: 0px;
553
+ right: 0px;
554
+ z-index: 10;
519
555
  }
520
556
 
521
- .phone-screen::-webkit-scrollbar-thumb:hover {
522
- 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;
523
569
  }
524
570
 
525
- @keyframes messageAppear {
526
- 0% {
527
- opacity: 0;
528
- transform: scale(0.8);
529
- }
530
- 70% {
531
- opacity: 1;
532
- transform: scale(1.05);
533
- }
534
- 100% {
535
- opacity: 1;
536
- transform: scale(1);
537
- }
571
+ .quick-replies-container {
572
+ display: flex;
573
+ flex-wrap: wrap;
574
+ justify-content: center;
575
+ gap: 6px;
576
+ z-index: 9;
538
577
  }
539
578
 
540
- .message {
541
- padding: 10px 14px;
542
- margin-bottom: 8px;
579
+ .quick-reply-btn {
580
+ padding: 4px 8px;
543
581
  border-radius: 18px;
544
- max-width: 70%;
545
- font-size: 13px;
546
- line-height: 1.2;
547
- }
548
- .message.animated {
549
- animation: messageAppear var(--animation-time) ease-out forwards;
550
- opacity: 0;
551
- }
552
- .message.incoming {
553
- background: #e5e5ea;
554
- color: #000;
555
- margin-right: auto;
556
- border-bottom-left-radius: 4px;
557
- }
558
- .message.outgoing {
559
- background: #007aff;
560
- color: white;
561
- margin-left: auto;
562
- text-align: left;
563
- border-bottom-right-radius: 4px;
564
- }
565
- .attachment-wrapper {
566
- max-width: 70%;
567
- margin-bottom: 8px;
568
- display: flex;
569
- flex-direction: column;
570
- gap: 4px;
571
- }
572
- .attachment-wrapper.incoming {
573
- margin-right: auto;
574
- align-items: flex-start;
575
- }
576
- .attachment-wrapper.outgoing {
577
- margin-left: auto;
578
- align-items: flex-end;
579
- }
580
- .attachment-wrapper.animated {
581
- animation: messageAppear var(--animation-time) ease-out forwards;
582
- opacity: 0;
583
- }
584
- .attachment {
585
- border-radius: 12px;
586
- overflow: hidden;
587
- max-width: 100%;
588
- }
589
- .attachment img {
590
- max-width: 100%;
591
- display: block;
592
- border-radius: 12px;
593
- }
594
- .attachment video {
595
- max-width: 100%;
596
- display: block;
597
- border-radius: 12px;
598
- }
599
- .attachment-audio {
600
- display: flex;
601
- align-items: center;
602
- gap: 8px;
603
- padding: 6px;
604
- background: white;
605
- border: 1px solid #e5e5ea;
606
- border-radius: 12px;
607
- min-width: 160px;
608
- }
609
- .attachment-wrapper.outgoing .attachment-audio {
582
+ border: 1px solid var(--color-primary, #007aff);
610
583
  background: white;
611
- border: none;
612
- }
613
- .attachment-audio audio {
614
- flex: 1;
615
- max-height: 30px;
616
- }
617
- .attachment-location {
618
- border-radius: 12px;
619
- overflow: hidden;
620
- }
621
- .event-info {
622
- text-align: center;
584
+ color: var(--color-primary, #007aff);
623
585
  font-size: 11px;
624
- color: #8e8e93;
625
- margin: 4px 0;
626
- padding: 0 10px;
627
- line-height: 1.3;
586
+ cursor: pointer;
587
+ transition: all 0.2s ease;
588
+ flex-shrink: 0;
628
589
  }
629
- .event-info.animated {
630
- animation: messageAppear var(--animation-time) ease-out forwards;
631
- opacity: 0;
590
+
591
+ .quick-reply-btn:hover:not(:disabled) {
592
+ background: var(--color-primary, #007aff);
593
+ color: white;
632
594
  }
595
+
596
+ .quick-reply-btn:disabled {
597
+ opacity: 0.5;
598
+ cursor: not-allowed;
599
+ }
600
+
633
601
  .message-input {
634
- background: linear-gradient(
635
- to top,
636
- rgba(0, 0, 0, 0.1) 0%,
637
- rgba(0, 0, 0, 0.05) 70%,
638
- transparent 100%
639
- );
640
602
  padding: 8px 12px;
641
603
  border-top: none;
642
604
  display: flex;
643
605
  align-items: center;
644
606
  gap: 8px;
645
- position: absolute;
646
- bottom: 0px;
647
- left: 0px;
648
- right: 0px;
649
607
  z-index: 10;
650
608
  }
651
609
  .message-input input {
652
610
  flex: 1;
653
- border: 1px solid #c6c6c8;
611
+ border: 1px solid #c6c6c857;
654
612
  border-radius: 20px;
655
613
  padding: 8px 15px;
656
614
  font-size: 15px;
657
615
  margin-bottom: 5px;
658
- background: white;
659
- border: none;
616
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
660
617
  outline: none;
661
618
  }
662
619
  .message-input input::placeholder {
@@ -667,7 +624,7 @@ export class Simulator extends RapidElement {
667
624
  height: 30px;
668
625
  border-radius: 50%;
669
626
  background: #fff;
670
- border: none;
627
+ border: 1px solid #c6c6c857;
671
628
  display: flex;
672
629
  align-items: center;
673
630
  justify-content: center;
@@ -675,6 +632,7 @@ export class Simulator extends RapidElement {
675
632
  flex-shrink: 0;
676
633
  margin-bottom: 5px;
677
634
  transition: all var(--animation-time) ease;
635
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
678
636
  color: #000;
679
637
  }
680
638
  .attachment-button:hover {
@@ -724,37 +682,6 @@ export class Simulator extends RapidElement {
724
682
  .attachment-menu-item temba-icon {
725
683
  color: #007aff;
726
684
  }
727
- .quick-replies {
728
- display: flex;
729
- flex-wrap: wrap;
730
- justify-content: center;
731
- gap: 8px;
732
- margin-top: 4px;
733
- margin-bottom: 8px;
734
- }
735
- .quick-reply-btn {
736
- background: white;
737
- color: #007aff;
738
- border: 1px solid #007aff;
739
- border-radius: 18px;
740
- padding: 4px 8px;
741
- font-size: 11px;
742
- cursor: pointer;
743
- transition: all var(--animation-time) ease;
744
- white-space: nowrap;
745
- }
746
- .quick-reply-btn:hover {
747
- background: #007aff;
748
- color: white;
749
- cursor: pointer;
750
- }
751
- .quick-reply-btn:active {
752
- transform: scale(0.95);
753
- }
754
- .quick-reply-btn.animated {
755
- animation: messageAppear var(--animation-time) ease-out forwards;
756
- opacity: 0;
757
- }
758
685
  `;
759
686
  }
760
687
 
@@ -771,9 +698,10 @@ export class Simulator extends RapidElement {
771
698
  size: 'small' | 'medium' | 'large';
772
699
 
773
700
  @property({ type: Array })
774
- private events: Event[] = [];
701
+ private events: ContactEvent[] = [];
775
702
 
776
703
  private previousEventCount = 0;
704
+ private chat: Chat = null;
777
705
 
778
706
  @property({ type: Object })
779
707
  private session: Session | null = null;
@@ -868,10 +796,130 @@ export class Simulator extends RapidElement {
868
796
  return config.contextWidth + config.contextOffset - config.phoneWidth;
869
797
  }
870
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
+
871
910
  protected updated(
872
911
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
873
912
  ): void {
874
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
+
875
923
  if (changes.has('flow') && this.flow) {
876
924
  this.endpoint = `/flow/simulate/${this.flow}/`;
877
925
  }
@@ -977,8 +1025,13 @@ export class Simulator extends RapidElement {
977
1025
  this.isVisible = true;
978
1026
  getStore().getState().setSimulatorActive(true);
979
1027
 
1028
+ // ensure chat component is available
1029
+ if (!this.chat) {
1030
+ this.chat = this.shadowRoot.querySelector('temba-chat');
1031
+ }
1032
+
980
1033
  // start the simulation if we haven't already
981
- if (this.events.length === 0) {
1034
+ if (!this.session) {
982
1035
  this.startFlow();
983
1036
  }
984
1037
  }
@@ -1004,20 +1057,37 @@ export class Simulator extends RapidElement {
1004
1057
  this.updateRunContext(response.json as RunContext);
1005
1058
  } catch (error) {
1006
1059
  console.error('Failed to start simulation:', error);
1007
- this.events = [
1008
- ...this.events,
1009
- {
1010
- type: 'error',
1011
- created_on: now,
1012
- text: 'Failed to start simulation'
1013
- } as any
1014
- ];
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
+ }
1015
1074
  }
1016
1075
  }
1017
1076
 
1018
- 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
1019
1081
  if (msgInEvt) {
1020
- 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);
1021
1091
  }
1022
1092
 
1023
1093
  if (runContext.session) {
@@ -1034,20 +1104,70 @@ export class Simulator extends RapidElement {
1034
1104
  this.context = runContext.context;
1035
1105
  }
1036
1106
 
1107
+ // extract quick replies from the most recent sprint
1108
+ this.currentQuickReplies = [];
1109
+
1037
1110
  if (runContext.events && runContext.events.length > 0) {
1038
- 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
+ }
1039
1116
 
1040
- // extract quick replies from the most recent sprint
1041
- this.currentQuickReplies = [];
1042
- for (const event of runContext.events) {
1043
- if (event.type === 'msg_created' && event.msg?.quick_replies) {
1044
- this.currentQuickReplies = event.msg.quick_replies;
1117
+ // skip msg_created events without a proper msg property
1118
+ if (rawEvent.type === 'msg_created' && !(rawEvent as any).msg) {
1119
+ continue;
1045
1120
  }
1121
+
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;
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);
1046
1158
  }
1047
1159
  }
1048
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
+
1049
1169
  this.sprinting = false;
1050
- this.requestUpdate();
1170
+ this.requestUpdate(); // trigger re-render for quick replies
1051
1171
  this.scrollToBottom();
1052
1172
  this.updateActivity();
1053
1173
  }
@@ -1126,6 +1246,19 @@ export class Simulator extends RapidElement {
1126
1246
  }
1127
1247
 
1128
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
+ }
1129
1262
  // wait for render, then scroll to bottom
1130
1263
  setTimeout(() => {
1131
1264
  const screen = this.shadowRoot?.querySelector('.phone-screen');
@@ -1145,6 +1278,43 @@ export class Simulator extends RapidElement {
1145
1278
  }, 50);
1146
1279
  }
1147
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
+
1148
1318
  private handleClose() {
1149
1319
  const phoneWindow = this.shadowRoot.getElementById(
1150
1320
  'phone-window'
@@ -1166,6 +1336,11 @@ export class Simulator extends RapidElement {
1166
1336
  this.previousEventCount = 0;
1167
1337
  this.currentQuickReplies = [];
1168
1338
 
1339
+ // reset chat component
1340
+ if (this.chat) {
1341
+ this.chat.reset();
1342
+ }
1343
+
1169
1344
  // Clear simulator activity data
1170
1345
  getStore().getState().updateSimulatorActivity({
1171
1346
  segments: {},
@@ -1385,20 +1560,36 @@ export class Simulator extends RapidElement {
1385
1560
  this.attachmentMenuOpen = false;
1386
1561
 
1387
1562
  const now = new Date().toISOString();
1388
- const msgInEvt: Event = {
1389
- uuid: crypto.randomUUID(),
1563
+
1564
+ // create the event for the API (with ISO string date)
1565
+ const msgInEvtForAPI = {
1566
+ uuid: generateUUIDv7(),
1390
1567
  type: 'msg_received',
1391
1568
  created_on: now,
1392
1569
  msg: {
1393
- uuid: crypto.randomUUID(),
1570
+ uuid: generateUUIDv7(),
1394
1571
  text: text || '',
1395
1572
  urn: this.contact.urns[0],
1396
- attachments: attachment ? [attachment] : []
1573
+ direction: 'in',
1574
+ type: 'text',
1575
+ attachments: attachment ? [attachment] : [],
1576
+ quick_replies: [],
1577
+ channel: { uuid: generateUUIDv7(), name: 'Simulator' }
1397
1578
  }
1398
1579
  };
1399
1580
 
1400
- // show user's message immediately
1401
- 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
+ }
1402
1593
  this.requestUpdate();
1403
1594
  this.scrollToBottom();
1404
1595
 
@@ -1407,7 +1598,7 @@ export class Simulator extends RapidElement {
1407
1598
  contact: this.contact,
1408
1599
  resume: {
1409
1600
  type: 'msg',
1410
- event: msgInEvt,
1601
+ event: msgInEvtForAPI,
1411
1602
  resumed_on: now
1412
1603
  }
1413
1604
  };
@@ -1422,14 +1613,20 @@ export class Simulator extends RapidElement {
1422
1613
  this.updateRunContext(response.json as RunContext, null);
1423
1614
  } catch (error) {
1424
1615
  console.error('Failed to resume simulation:', error);
1425
- this.events = [
1426
- ...this.events,
1427
- {
1428
- type: 'error',
1429
- created_on: now,
1430
- text: 'Failed to send message'
1431
- } as any
1432
- ];
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
+ }
1433
1630
  this.sprinting = false;
1434
1631
  }
1435
1632
  }
@@ -1449,9 +1646,9 @@ export class Simulator extends RapidElement {
1449
1646
  this.inputValue = input.value;
1450
1647
  }
1451
1648
 
1452
- private handleQuickReply(quickReply: string) {
1453
- if (!this.sprinting) {
1454
- this.resume(quickReply);
1649
+ private handleQuickReplyClick(text: string) {
1650
+ if (!this.sprinting && text) {
1651
+ this.resume(text);
1455
1652
  }
1456
1653
  }
1457
1654
 
@@ -1506,364 +1703,16 @@ export class Simulator extends RapidElement {
1506
1703
  }
1507
1704
  }
1508
1705
 
1509
- private getEventDescription(event: Event): string | null {
1510
- switch (event.type) {
1511
- case 'contact_groups_changed': {
1512
- const groups = (event as any).groups_added || [];
1513
- const removedGroups = (event as any).groups_removed || [];
1514
- if (groups.length > 0) {
1515
- const groupNames = groups.map((g: any) => `"${g.name}"`).join(', ');
1516
- return `Added to ${groupNames}`;
1517
- }
1518
- if (removedGroups.length > 0) {
1519
- const groupNames = removedGroups
1520
- .map((g: any) => `"${g.name}"`)
1521
- .join(', ');
1522
- return `Removed from ${groupNames}`;
1523
- }
1524
- break;
1525
- }
1526
- case 'contact_field_changed': {
1527
- const field = (event as any).field;
1528
- const value = (event as any).value;
1529
- const valueText = value ? value.text || value : '';
1530
- if (field) {
1531
- if (valueText) {
1532
- return `Set contact "${field.name}" to "${valueText}"`;
1533
- } else {
1534
- return `Cleared contact "${field.name}"`;
1535
- }
1536
- }
1537
- break;
1538
- }
1539
- case 'contact_language_changed':
1540
- return `Set preferred language to "${(event as any).language}"`;
1541
- case 'contact_name_changed':
1542
- return `Set contact name to "${(event as any).name}"`;
1543
- case 'contact_status_changed':
1544
- return `Set status to "${(event as any).status}"`;
1545
- case 'contact_urns_changed':
1546
- return `Added a URN for the contact`;
1547
- case 'input_labels_added': {
1548
- const labels = (event as any).labels || [];
1549
- if (labels.length > 0) {
1550
- const labelNames = labels.map((l: any) => `"${l.name}"`).join(', ');
1551
- return `Message labeled with ${labelNames}`;
1552
- }
1553
- break;
1554
- }
1555
- case 'run_result_changed':
1556
- return `Set result "${(event as any).name}" to "${
1557
- (event as any).value
1558
- }"`;
1559
- case 'run_started':
1560
- case 'flow_entered': {
1561
- const flow = (event as any).flow;
1562
- if (flow) {
1563
- return `Entered flow "${flow.name}"`;
1564
- }
1565
- break;
1566
- }
1567
- case 'run_ended': {
1568
- const flow = (event as any).flow;
1569
- if (flow) {
1570
- return `Exited flow "${flow.name}"`;
1571
- }
1572
- break;
1573
- }
1574
- case 'email_created':
1575
- case 'email_sent': {
1576
- const recipients = (event as any).to || (event as any).addresses || [];
1577
- const subject = (event as any).subject;
1578
- const recipientList = recipients
1579
- .map((r: string) => `"${r}"`)
1580
- .join(', ');
1581
- return `Sent email to ${recipientList} with subject "${subject}"`;
1582
- }
1583
- case 'broadcast_created': {
1584
- const translations = (event as any).translations;
1585
- const baseLanguage = (event as any).base_language;
1586
- if (translations && translations[baseLanguage]) {
1587
- return `Sent broadcast: "${translations[baseLanguage].text}"`;
1588
- }
1589
- return `Sent broadcast`;
1590
- }
1591
- case 'session_triggered': {
1592
- const flow = (event as any).flow;
1593
- if (flow) {
1594
- return `Started somebody else in "${flow.name}"`;
1595
- }
1596
- break;
1597
- }
1598
- case 'ticket_opened': {
1599
- const ticket = (event as any).ticket;
1600
- if (ticket && ticket.topic) {
1601
- return `Ticket opened with topic "${ticket.topic.name}"`;
1602
- }
1603
- return `Ticket opened`;
1604
- }
1605
- case 'resthook_called':
1606
- return `Triggered flow event "${(event as any).resthook}"`;
1607
- case 'webhook_called':
1608
- return `Called ${(event as any).url}`;
1609
- case 'service_called': {
1610
- const service = (event as any).service;
1611
- if (service === 'classifier') {
1612
- return `Called classifier`;
1613
- }
1614
- return `Called ${service}`;
1615
- }
1616
- case 'airtime_transferred': {
1617
- const amount = (event as any).actual_amount;
1618
- const currency = (event as any).currency;
1619
- const recipient = (event as any).recipient;
1620
- if (amount && currency && recipient) {
1621
- return `Transferred ${amount} ${currency} to ${recipient}`;
1622
- }
1623
- break;
1624
- }
1625
- case 'info':
1626
- return (event as any).text;
1627
- case 'warning':
1628
- return `⚠️ ${(event as any).text}`;
1629
- }
1630
- return null;
1631
- }
1632
-
1633
- private renderAlertMessage(
1634
- type: 'error' | 'warning' | 'failure',
1635
- text: string,
1636
- animatedClass: string,
1637
- animationDelay: string
1638
- ): TemplateResult {
1639
- const config = {
1640
- error: {
1641
- icon: '❗',
1642
- bgColor: '#fee2e2',
1643
- textColor: '#991b1b',
1644
- defaultText: 'An error occurred'
1645
- },
1646
- warning: {
1647
- icon: '⚠️',
1648
- bgColor: '#fef3c7',
1649
- textColor: 'rgba(125, 87, 18, 0.8)',
1650
- defaultText: 'A warning occurred'
1651
- },
1652
- failure: {
1653
- icon: '💥',
1654
- bgColor: '#fee2e2',
1655
- textColor: '#991b1b',
1656
- defaultText: 'A failure occurred'
1657
- }
1658
- }[type];
1659
-
1660
- return html`
1661
- <div
1662
- class="event-info ${animatedClass}"
1663
- style="display:flex; align-items:center; background: ${config.bgColor}; color: ${config.textColor}; padding: 6px; margin: 4px 12px; border-radius: 8px; animation-delay: ${animationDelay}"
1664
- >
1665
- <div style="padding:4px;margin-right:6px;font-size:15px">
1666
- ${config.icon}
1667
- </div>
1668
- <div style="padding-right:2px;text-align:left">
1669
- ${text || config.defaultText}
1670
- </div>
1671
- </div>
1672
- `;
1673
- }
1674
-
1675
- private renderAttachment(attachment: string): TemplateResult {
1676
- // parse attachment format: "type/subtype:url" or "geo:lat,long"
1677
- const parts = attachment.split(':');
1678
- const type = parts[0];
1679
- const content = parts.slice(1).join(':'); // rejoin in case url has colons
1680
-
1681
- if (type === 'geo') {
1682
- // use temba-thumbnail for location to get map image
1683
- return html`
1684
- <div class="attachment-location">
1685
- <temba-thumbnail attachment="${attachment}"></temba-thumbnail>
1686
- </div>
1687
- `;
1688
- } else if (type.startsWith('image/')) {
1689
- // custom image rendering
1690
- return html`
1691
- <div class="attachment">
1692
- <img src="${content}" alt="Image attachment" />
1693
- </div>
1694
- `;
1695
- } else if (type.startsWith('video/')) {
1696
- // custom video rendering
1697
- return html`
1698
- <div class="attachment">
1699
- <video controls>
1700
- <source src="${content}" type="${type}" />
1701
- </video>
1702
- </div>
1703
- `;
1704
- } else if (type.startsWith('audio/')) {
1705
- // custom audio rendering
1706
- return html`
1707
- <div class="attachment">
1708
- <div class="attachment-audio">
1709
- <audio controls>
1710
- <source src="${content}" type="${type}" />
1711
- </audio>
1712
- </div>
1713
- </div>
1714
- `;
1715
- }
1716
-
1717
- // fallback for unknown types
1718
- return html`
1719
- <div class="attachment">
1720
- <span>Attachment</span>
1721
- </div>
1722
- `;
1723
- }
1724
-
1725
- private renderMessages(): TemplateResult {
1726
- if (this.events.length === 0) {
1727
- return html`
1728
- <div class="message incoming">👋 Welcome! Starting simulation...</div>
1729
- `;
1730
- }
1731
-
1732
- const eventTemplates = this.events.map((event, index) => {
1733
- // only animate messages that are new (beyond previous count)
1734
- const isNew = index >= this.previousEventCount;
1735
- const animatedClass = isNew ? 'animated' : '';
1736
- // stagger animations for new messages
1737
- const animationDelay = isNew
1738
- ? `${(index - this.previousEventCount) * 0.2}s`
1739
- : '0s';
1740
-
1741
- if (event.type === 'msg_received' && event.msg) {
1742
- const hasAttachments =
1743
- event.msg.attachments && event.msg.attachments.length > 0;
1744
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1745
-
1746
- return html`
1747
- ${hasAttachments
1748
- ? html`
1749
- <div
1750
- class="attachment-wrapper outgoing ${animatedClass}"
1751
- style="animation-delay: ${animationDelay}"
1752
- >
1753
- ${event.msg.attachments.map((att: string) =>
1754
- this.renderAttachment(att)
1755
- )}
1756
- </div>
1757
- `
1758
- : html``}
1759
- ${hasText
1760
- ? html`
1761
- <div
1762
- class="message outgoing ${animatedClass}"
1763
- style="animation-delay: ${animationDelay}"
1764
- >
1765
- ${event.msg.text}
1766
- </div>
1767
- `
1768
- : html``}
1769
- `;
1770
- } else if (event.type === 'msg_created' && event.msg) {
1771
- const hasAttachments =
1772
- event.msg.attachments && event.msg.attachments.length > 0;
1773
- const hasText = event.msg.text && event.msg.text.trim().length > 0;
1774
-
1775
- return html`
1776
- ${hasAttachments
1777
- ? html`
1778
- <div
1779
- class="attachment-wrapper incoming ${animatedClass}"
1780
- style="animation-delay: ${animationDelay}"
1781
- >
1782
- ${event.msg.attachments.map((att: string) =>
1783
- this.renderAttachment(att)
1784
- )}
1785
- </div>
1786
- `
1787
- : html``}
1788
- ${hasText
1789
- ? html`
1790
- <div
1791
- class="message incoming ${animatedClass}"
1792
- style="animation-delay: ${animationDelay}"
1793
- >
1794
- ${event.msg.text}
1795
- </div>
1796
- `
1797
- : html``}
1798
- `;
1799
- } else if (event.type === 'failure') {
1800
- return this.renderAlertMessage(
1801
- 'failure',
1802
- (event as any).text,
1803
- animatedClass,
1804
- animationDelay
1805
- );
1806
- } else if (event.type === 'warning') {
1807
- return this.renderAlertMessage(
1808
- 'warning',
1809
- (event as any).text,
1810
- animatedClass,
1811
- animationDelay
1812
- );
1813
- } else if (event.type === 'error') {
1814
- return this.renderAlertMessage(
1815
- 'error',
1816
- (event as any).text,
1817
- animatedClass,
1818
- animationDelay
1819
- );
1820
- } else {
1821
- // check if this is an event we should display
1822
- const description = this.getEventDescription(event);
1823
- if (description) {
1824
- return html`
1825
- <div
1826
- class="event-info ${animatedClass}"
1827
- style="animation-delay: ${animationDelay}"
1828
- >
1829
- ${description}
1830
- </div>
1831
- `;
1832
- }
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`);
1833
1714
  }
1834
- return html``;
1835
1715
  });
1836
-
1837
- // render quick replies at the end if we have any from the most recent sprint
1838
- const hasQuickReplies = this.currentQuickReplies.length > 0;
1839
- const quickRepliesAnimationDelay =
1840
- this.events.length >= this.previousEventCount
1841
- ? `${(this.events.length - this.previousEventCount) * 0.2}s`
1842
- : '0s';
1843
-
1844
- return html`
1845
- ${eventTemplates}
1846
- ${hasQuickReplies
1847
- ? html`
1848
- <div
1849
- class="quick-replies animated"
1850
- style="animation-delay: ${quickRepliesAnimationDelay}"
1851
- >
1852
- ${this.currentQuickReplies.map(
1853
- (qr: any) => html`
1854
- <button
1855
- class="quick-reply-btn animated"
1856
- style="animation-delay: ${quickRepliesAnimationDelay}"
1857
- @click=${() => this.handleQuickReply(qr.text)}
1858
- >
1859
- ${qr.text}
1860
- </button>
1861
- `
1862
- )}
1863
- </div>
1864
- `
1865
- : html``}
1866
- `;
1867
1716
  }
1868
1717
 
1869
1718
  protected render(): TemplateResult {
@@ -1887,10 +1736,12 @@ export class Simulator extends RapidElement {
1887
1736
  --cutout-island-width: ${config.cutoutIslandWidth}px;
1888
1737
  --cutout-island-height: ${config.cutoutIslandHeight}px;
1889
1738
  --cutout-island-top: ${config.cutoutIslandTop}px;
1739
+ --animation-time: ${this.animationTime}ms;
1890
1740
  `;
1891
1741
 
1892
1742
  return html`
1893
1743
  <temba-floating-window
1744
+ style="--transition-duration: ${this.animationTime}ms"
1894
1745
  id="phone-window"
1895
1746
  width="${this.windowWidth}"
1896
1747
  leftBoundaryMargin="${this.leftBoundaryMargin}"
@@ -1955,56 +1806,80 @@ export class Simulator extends RapidElement {
1955
1806
  <div class="dynamic-island"></div>
1956
1807
  </div>
1957
1808
  </div>
1958
- <div class="phone-screen">${this.renderMessages()}</div>
1959
- <div class="message-input">
1960
- <button
1961
- class="attachment-button"
1962
- @click=${this.handleToggleAttachmentMenu}
1963
- ?disabled=${this.sprinting}
1964
- >
1965
- <temba-icon name="plus" size="1.5"></temba-icon>
1966
- </button>
1967
- <input
1968
- type="text"
1969
- placeholder="Enter Message"
1970
- .value=${this.inputValue}
1971
- @input=${this.handleInput}
1972
- @keyup=${this.handleKeyUp}
1973
- ?disabled=${this.sprinting}
1974
- />
1975
- <div
1976
- class="attachment-menu ${this.attachmentMenuOpen ? 'open' : ''}"
1977
- >
1978
- <div
1979
- class="attachment-menu-item"
1980
- @click=${() => this.handleSendAttachment('image')}
1981
- >
1982
- <temba-icon name="attachment_image" size="1.2"></temba-icon>
1983
- <span>Image</span>
1984
- </div>
1985
- <div
1986
- class="attachment-menu-item"
1987
- @click=${() => this.handleSendAttachment('video')}
1988
- >
1989
- <temba-icon name="attachment_video" size="1.2"></temba-icon>
1990
- <span>Video</span>
1991
- </div>
1992
- <div
1993
- class="attachment-menu-item"
1994
- @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}
1995
1836
  >
1996
- <temba-icon name="attachment_audio" size="1.2"></temba-icon>
1997
- <span>Audio</span>
1998
- </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
+ />
1999
1847
  <div
2000
- class="attachment-menu-item"
2001
- @click=${() => this.handleSendAttachment('location')}
1848
+ class="attachment-menu ${this.attachmentMenuOpen
1849
+ ? 'open'
1850
+ : ''}"
2002
1851
  >
2003
- <temba-icon
2004
- name="attachment_location"
2005
- size="1.2"
2006
- ></temba-icon>
2007
- <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>
2008
1883
  </div>
2009
1884
  </div>
2010
1885
  </div>