@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.
- package/CHANGELOG.md +10 -0
- package/demo/components/webchat/example.html +2 -2
- package/dist/temba-components.js +487 -551
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/display/Chat.js +123 -44
- package/out-tsc/src/display/Chat.js.map +1 -1
- package/out-tsc/src/events/eventRenderers.js +442 -0
- package/out-tsc/src/events/eventRenderers.js.map +1 -0
- package/out-tsc/src/flow/Editor.js +3 -2
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +0 -1
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/list/ShortcutList.js +1 -1
- package/out-tsc/src/list/ShortcutList.js.map +1 -1
- package/out-tsc/src/live/ContactChat.js +12 -321
- package/out-tsc/src/live/ContactChat.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +428 -571
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/out-tsc/test/temba-simulator.test.js +51 -32
- package/out-tsc/test/temba-simulator.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/contacts/chat-failure.png +0 -0
- package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
- package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
- package/screenshots/truth/simulator/after-message-sent.png +0 -0
- package/screenshots/truth/simulator/after-reset.png +0 -0
- package/screenshots/truth/simulator/attachment-menu.png +0 -0
- package/screenshots/truth/simulator/context-expanded.png +0 -0
- package/screenshots/truth/simulator/context-explorer-open.png +0 -0
- package/screenshots/truth/simulator/event-info.png +0 -0
- package/screenshots/truth/simulator/image-attachment.png +0 -0
- package/screenshots/truth/simulator/open-initial.png +0 -0
- package/screenshots/truth/simulator/quick-replies.png +0 -0
- package/src/display/Chat.ts +123 -44
- package/src/events/eventRenderers.ts +527 -0
- package/src/flow/Editor.ts +3 -2
- package/src/flow/NodeEditor.ts +0 -1
- package/src/list/ShortcutList.ts +1 -1
- package/src/live/ContactChat.ts +17 -376
- package/src/simulator/Simulator.ts +487 -612
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
.
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
.
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
.
|
|
541
|
-
padding:
|
|
542
|
-
margin-bottom: 8px;
|
|
579
|
+
.quick-reply-btn {
|
|
580
|
+
padding: 4px 8px;
|
|
543
581
|
border-radius: 18px;
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
line-height: 1.3;
|
|
586
|
+
cursor: pointer;
|
|
587
|
+
transition: all 0.2s ease;
|
|
588
|
+
flex-shrink: 0;
|
|
628
589
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 #
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
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:
|
|
1570
|
+
uuid: generateUUIDv7(),
|
|
1394
1571
|
text: text || '',
|
|
1395
1572
|
urn: this.contact.urns[0],
|
|
1396
|
-
|
|
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
|
-
//
|
|
1401
|
-
|
|
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:
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
1453
|
-
if (!this.sprinting) {
|
|
1454
|
-
this.resume(
|
|
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
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
<
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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="
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
|
2001
|
-
|
|
1848
|
+
class="attachment-menu ${this.attachmentMenuOpen
|
|
1849
|
+
? 'open'
|
|
1850
|
+
: ''}"
|
|
2002
1851
|
>
|
|
2003
|
-
<
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
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>
|