@nyaruka/temba-components 0.91.6 → 0.92.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 (83) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/demo/index.html +1 -1
  3. package/dist/temba-components.js +760 -1189
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/chat/Chat.js +714 -0
  6. package/out-tsc/src/chat/Chat.js.map +1 -0
  7. package/out-tsc/src/completion/helpers.js +1 -29
  8. package/out-tsc/src/completion/helpers.js.map +1 -1
  9. package/out-tsc/src/compose/Compose.js +6 -2
  10. package/out-tsc/src/compose/Compose.js.map +1 -1
  11. package/out-tsc/src/contacts/ContactChat.js +518 -54
  12. package/out-tsc/src/contacts/ContactChat.js.map +1 -1
  13. package/out-tsc/src/contacts/events.js +1 -998
  14. package/out-tsc/src/contacts/events.js.map +1 -1
  15. package/out-tsc/src/lightbox/Lightbox.js +4 -0
  16. package/out-tsc/src/lightbox/Lightbox.js.map +1 -1
  17. package/out-tsc/src/list/TembaMenu.js +0 -1
  18. package/out-tsc/src/list/TembaMenu.js.map +1 -1
  19. package/out-tsc/src/markdown.js +33 -0
  20. package/out-tsc/src/markdown.js.map +1 -0
  21. package/out-tsc/src/select/Select.js +6 -1
  22. package/out-tsc/src/select/Select.js.map +1 -1
  23. package/out-tsc/src/textinput/TextInput.js +1 -1
  24. package/out-tsc/src/textinput/TextInput.js.map +1 -1
  25. package/out-tsc/src/thumbnail/Thumbnail.js +128 -81
  26. package/out-tsc/src/thumbnail/Thumbnail.js.map +1 -1
  27. package/out-tsc/src/utils/index.js +9 -11
  28. package/out-tsc/src/utils/index.js.map +1 -1
  29. package/out-tsc/src/webchat/WebChat.js +109 -358
  30. package/out-tsc/src/webchat/WebChat.js.map +1 -1
  31. package/out-tsc/src/webchat/index.js +17 -0
  32. package/out-tsc/src/webchat/index.js.map +1 -1
  33. package/out-tsc/temba-modules.js +2 -2
  34. package/out-tsc/temba-modules.js.map +1 -1
  35. package/out-tsc/temba-webchat.js +2 -0
  36. package/out-tsc/temba-webchat.js.map +1 -1
  37. package/out-tsc/test/temba-contact-chat.test.js +1 -0
  38. package/out-tsc/test/temba-contact-chat.test.js.map +1 -1
  39. package/out-tsc/test/temba-lightbox.test.js +4 -4
  40. package/out-tsc/test/temba-lightbox.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/screenshots/truth/contacts/compose-attachments-no-text-failure.png +0 -0
  43. package/screenshots/truth/contacts/compose-attachments-no-text-success.png +0 -0
  44. package/screenshots/truth/contacts/compose-text-and-attachments-failure-attachments.png +0 -0
  45. package/screenshots/truth/contacts/compose-text-and-attachments-failure-generic.png +0 -0
  46. package/screenshots/truth/contacts/compose-text-and-attachments-failure-text-and-attachments.png +0 -0
  47. package/screenshots/truth/contacts/compose-text-and-attachments-failure-text.png +0 -0
  48. package/screenshots/truth/contacts/compose-text-and-attachments-success.png +0 -0
  49. package/screenshots/truth/contacts/compose-text-no-attachments-failure.png +0 -0
  50. package/screenshots/truth/contacts/compose-text-no-attachments-success.png +0 -0
  51. package/screenshots/truth/contacts/contact-active-default.png +0 -0
  52. package/screenshots/truth/contacts/contact-active-show-chatbox.png +0 -0
  53. package/screenshots/truth/contacts/contact-archived-hide-chatbox.png +0 -0
  54. package/screenshots/truth/contacts/contact-blocked-hide-chatbox.png +0 -0
  55. package/screenshots/truth/contacts/contact-stopped-hide-chatbox.png +0 -0
  56. package/screenshots/truth/lightbox/img-zoomed.png +0 -0
  57. package/screenshots/truth/lightbox/img.png +0 -0
  58. package/src/chat/Chat.ts +791 -0
  59. package/src/completion/helpers.ts +2 -40
  60. package/src/compose/Compose.ts +6 -2
  61. package/src/contacts/ContactChat.ts +609 -59
  62. package/src/contacts/events.ts +1 -1068
  63. package/src/lightbox/Lightbox.ts +5 -0
  64. package/src/list/TembaMenu.ts +0 -1
  65. package/src/markdown.ts +41 -0
  66. package/src/select/Select.ts +5 -1
  67. package/src/textinput/TextInput.ts +1 -1
  68. package/src/thumbnail/Thumbnail.ts +130 -81
  69. package/src/utils/index.ts +12 -13
  70. package/src/webchat/WebChat.ts +196 -413
  71. package/src/webchat/index.ts +23 -1
  72. package/static/css/temba-components.css +2 -0
  73. package/temba-modules.ts +2 -2
  74. package/temba-webchat.ts +2 -0
  75. package/test/temba-contact-chat.test.ts +1 -0
  76. package/test/temba-lightbox.test.ts +4 -4
  77. package/test-assets/contacts/history.json +1 -56
  78. package/out-tsc/src/contacts/ContactHistory.js +0 -691
  79. package/out-tsc/src/contacts/ContactHistory.js.map +0 -1
  80. package/out-tsc/test/temba-contact-history.test.js +0 -69
  81. package/out-tsc/test/temba-contact-history.test.js.map +0 -1
  82. package/src/contacts/ContactHistory.ts +0 -875
  83. package/test/temba-contact-history.test.ts +0 -107
@@ -0,0 +1,791 @@
1
+ import { TemplateResult, html, PropertyValueMap, css } from 'lit';
2
+ import { property } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { CustomEventType } from '../interfaces';
5
+ import { DEFAULT_AVATAR } from '../webchat/assets';
6
+ import { hashCode, renderAvatar } from '../utils';
7
+ import { renderMarkdown } from '../markdown';
8
+
9
+ const BATCH_TIME_WINDOW = 60 * 60 * 1000;
10
+ const SCROLL_FETCH_BUFFER = 0.05;
11
+ const MIN_FETCH_TIME = 250;
12
+
13
+ export enum MessageType {
14
+ Inline = 'inline',
15
+ Error = 'error',
16
+ Collapse = 'collapse',
17
+ Note = 'note',
18
+ MsgIn = 'msg_in',
19
+ MsgOut = 'msg_out'
20
+ }
21
+
22
+ interface User {
23
+ avatar?: string;
24
+ email: string;
25
+ name: string;
26
+ }
27
+
28
+ export interface ChatEvent {
29
+ id?: string;
30
+ type: MessageType;
31
+ text: string;
32
+ date: Date;
33
+ user?: User;
34
+ popup?: TemplateResult;
35
+ }
36
+
37
+ export interface Message extends ChatEvent {
38
+ sendError?: boolean;
39
+ attachments?: string[];
40
+ }
41
+
42
+ const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' } as any;
43
+ const VERBOSE_FORMAT = {
44
+ weekday: undefined,
45
+ year: undefined,
46
+ month: 'short',
47
+ day: 'numeric',
48
+ hour: 'numeric',
49
+ minute: '2-digit'
50
+ } as any;
51
+
52
+ export class Chat extends RapidElement {
53
+ static get styles() {
54
+ return css`
55
+ :host {
56
+ display: flex;
57
+ flex-direction: column;
58
+ flex-grow: 1;
59
+ }
60
+
61
+ .block {
62
+ margin-bottom: 1em;
63
+ }
64
+
65
+ .block.collapse {
66
+ margin: 0;
67
+ align-items: center;
68
+ display: flex;
69
+ flex-direction: column;
70
+ margin-bottom: 0.5em;
71
+ }
72
+
73
+ .block.collapse .messsage {
74
+ transform: scaleY(0);
75
+ margin: 0;
76
+ padding: 0;
77
+ line-height: 0;
78
+ }
79
+
80
+ .time {
81
+ text-align: center;
82
+ font-size: 0.8em;
83
+ color: #999;
84
+ margin-top: 2em;
85
+ border-top: 1px solid #e9e9e9;
86
+ padding: 1em;
87
+ margin-left: 10%;
88
+ margin-right: 10%;
89
+ }
90
+
91
+ .time.first {
92
+ border-top: none;
93
+ margin-top: 0;
94
+ border-bottom: 1px solid #e9e9e9;
95
+ margin-bottom: 2em;
96
+ }
97
+
98
+ .first .time {
99
+ margin-top: 0;
100
+ border-top: none;
101
+ padding-top: 0;
102
+ }
103
+
104
+ .row {
105
+ display: flex;
106
+ flex-direction: row;
107
+ align-items: flex-start;
108
+ margin-bottom: 0.25em;
109
+ }
110
+
111
+ .input-panel {
112
+ padding: 1em;
113
+ background: #fff;
114
+ }
115
+
116
+ .avatar {
117
+ margin-right: 0.6em;
118
+ margin-left: 0.6em;
119
+ width: 2em;
120
+ align-self: flex-end;
121
+ }
122
+
123
+ .toggle {
124
+ flex-shrink: 0;
125
+ width: 4em;
126
+ height: 4em;
127
+ overflow: hidden;
128
+ border-radius: 100%;
129
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 1em 0.7em,
130
+ rgba(0, 0, 0, 0.2) 0px 1px 2px 0px,
131
+ inset 0 0 0 0.25em rgba(0, 0, 0, 0.1);
132
+ cursor: pointer;
133
+ transition: box-shadow var(--toggle-speed, 200ms) ease-out;
134
+ position: absolute;
135
+ bottom: 1em;
136
+ right: 1em;
137
+ }
138
+
139
+ .toggle:hover {
140
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 1em 0.7em,
141
+ rgba(0, 0, 0, 0.4) 0px 1px 2px 0px,
142
+ inset 0 0 0 0.25em rgba(0, 0, 0, 0.2);
143
+ }
144
+
145
+ .incoming .row {
146
+ flex-direction: row-reverse;
147
+ margin-left: 1em;
148
+ }
149
+
150
+ .bubble {
151
+ padding: 0.75em;
152
+ padding-bottom: 0.25em;
153
+ background: var(--color-chat-in, #f1f1f1);
154
+ border-radius: var(--curvature);
155
+ }
156
+
157
+ .bubble .name {
158
+ font-size: 0.95em;
159
+ font-weight: 400;
160
+ color: rgba(0, 0, 0, 0.4);
161
+ margin-bottom: 0.25em;
162
+ }
163
+
164
+ .outgoing .latest .bubble {
165
+ border-bottom-left-radius: 0;
166
+ }
167
+
168
+ .incoming .bubble {
169
+ background: var(--color-chat-out, #3c92dd);
170
+ color: white;
171
+ }
172
+
173
+ .incoming .latest .bubble {
174
+ border-bottom-right-radius: 0;
175
+ }
176
+
177
+ .incoming .bubble .name {
178
+ color: rgba(255, 255, 255, 0.7);
179
+ }
180
+
181
+ .note .bubble {
182
+ background: #fff47b;
183
+ color: rgba(0, 0, 0, 0.6);
184
+ }
185
+
186
+ .message {
187
+ margin-bottom: 0.5em;
188
+ line-height: 1.2em;
189
+ }
190
+
191
+ .chat {
192
+ width: 28rem;
193
+ border-radius: var(--curvature);
194
+ overflow: hidden;
195
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 3px 7px 0px,
196
+ rgba(0, 0, 0, 0.2) 0px 1px 2px 0px, rgba(0, 0, 0, 0.1) 5em 5em 5em 5em;
197
+ position: absolute;
198
+ bottom: 3em;
199
+ right: 1em;
200
+ transition: all var(--toggle-speed, 200ms) ease-out;
201
+ transform: scale(0.9);
202
+ pointer-events: none;
203
+ opacity: 0;
204
+ }
205
+
206
+ .chat.open {
207
+ bottom: 6em;
208
+ opacity: 1;
209
+ transform: scale(1);
210
+ pointer-events: initial;
211
+ }
212
+
213
+ .messages {
214
+ background: #fff;
215
+ position: relative;
216
+ flex-grow: 1;
217
+ overflow: hidden;
218
+ }
219
+
220
+ .scroll {
221
+ position: absolute;
222
+ top: 0;
223
+ bottom: 0;
224
+ right: 0;
225
+ left: 0;
226
+ overflow: auto;
227
+ -webkit-overflow-scrolling: touch;
228
+ overflow-scrolling: touch;
229
+ padding: 1em 1em 1em 1em;
230
+ display: flex;
231
+ flex-direction: column-reverse;
232
+ }
233
+
234
+ .messages:before {
235
+ content: '';
236
+ background: radial-gradient(
237
+ farthest-side at 50% 0,
238
+ rgba(0, 0, 0, 0.2),
239
+ rgba(0, 0, 0, 0)
240
+ )
241
+ center top;
242
+ height: 10px;
243
+ display: block;
244
+ position: absolute;
245
+ width: 100%;
246
+ transition: opacity var(--toggle-speed, 200ms) ease-out;
247
+ z-index: 1;
248
+ }
249
+
250
+ .messages:after {
251
+ content: '';
252
+ background: radial-gradient(
253
+ farthest-side at 50% 100%,
254
+ rgba(0, 0, 0, 0.2),
255
+ rgba(0, 0, 0, 0)
256
+ )
257
+ center bottom;
258
+ height: 10px;
259
+ display: block;
260
+ position: absolute;
261
+ bottom: 0;
262
+ margin-top: -10px;
263
+ width: 100%;
264
+ margin-right: 5em;
265
+ transition: opacity var(--toggle-speed, 200ms) ease-out;
266
+ z-index: 1;
267
+ }
268
+
269
+ .bubble-wrap {
270
+ position: relative;
271
+ max-width: 70%;
272
+ display: flex;
273
+ flex-direction: column;
274
+ align-items: center;
275
+ margin: -0.5em -2em;
276
+ padding: 0.5em 2em;
277
+ }
278
+
279
+ .scroll-at-top.messages:before {
280
+ opacity: 0;
281
+ }
282
+
283
+ .scroll-at-bottom.messages:after {
284
+ opacity: 0;
285
+ }
286
+
287
+ .input {
288
+ border: none;
289
+ flex-grow: 1;
290
+ color: #333;
291
+ font-size: 1em;
292
+ }
293
+
294
+ .input:focus {
295
+ outline: none;
296
+ }
297
+
298
+ input::placeholder {
299
+ opacity: 0.3;
300
+ }
301
+
302
+ .input.inactive {
303
+ // pointer-events: none;
304
+ // opacity: 0.3;
305
+ }
306
+
307
+ .active {
308
+ }
309
+
310
+ .send-icon {
311
+ color: #eee;
312
+ pointer-events: none;
313
+ transform: rotate(-45deg);
314
+ transition: transform 0.2s ease-out;
315
+ }
316
+
317
+ .pending .send-icon {
318
+ color: var(--color-primary-dark);
319
+ pointer-events: initial;
320
+ transform: rotate(0deg);
321
+ }
322
+
323
+ .notice {
324
+ padding: 1em;
325
+ background: #f8f8f8;
326
+ color: #666;
327
+ text-align: center;
328
+ cursor: pointer;
329
+ }
330
+
331
+ .connecting .notice {
332
+ display: flex;
333
+ justify-content: center;
334
+ }
335
+
336
+ .connecting .notice temba-icon {
337
+ margin-left: 0.5em;
338
+ }
339
+
340
+ .reconnect {
341
+ color: var(--color-primary-dark);
342
+ text-decoration: underline;
343
+ font-size: 0.9em;
344
+ }
345
+
346
+ .input:disabled {
347
+ background: transparent !important;
348
+ }
349
+
350
+ temba-loading {
351
+ justify-content: center;
352
+ margin: 0.5em auto;
353
+ margin-bottom: 2em;
354
+ }
355
+
356
+ temba-loading.hidden {
357
+ display: none;
358
+ }
359
+
360
+ .inline {
361
+ }
362
+
363
+ .event {
364
+ flex-grow: 1;
365
+ align-self: center;
366
+ display: flex;
367
+ flex-direction: column;
368
+ align-items: center;
369
+ }
370
+
371
+ .event p {
372
+ margin: 0;
373
+ padding: 0;
374
+ }
375
+
376
+ .collapse {
377
+ }
378
+
379
+ a {
380
+ color: var(--color-primary-dark);
381
+ }
382
+
383
+ .attachments {
384
+ display: flex;
385
+ flex-direction: row;
386
+ flex-wrap: wrap;
387
+ align-items: center;
388
+ align-self: flex-start;
389
+ }
390
+
391
+ .incoming .attachments {
392
+ align-self: flex-end;
393
+ }
394
+
395
+ temba-thumbnail {
396
+ margin: 0.4em;
397
+ border-radius: var(--curvature);
398
+ }
399
+
400
+ .error .bubble {
401
+ border: 1px solid var(--color-error);
402
+ background: white;
403
+ color: #333;
404
+ }
405
+
406
+ .error .bubble .name {
407
+ color: #999;
408
+ }
409
+
410
+ .error temba-thumbnail {
411
+ --thumb-background: var(--color-error);
412
+ --thumb-icon: white;
413
+ }
414
+
415
+ .popup {
416
+ align-self: center;
417
+ display: flex;
418
+ position: absolute;
419
+ background: #fff;
420
+ padding: 0.5em 1em;
421
+ justify-content: center;
422
+ text-align: center;
423
+ border-radius: var(--curvature);
424
+ box-shadow: rgba(0, 0, 0, 0.05) 0px 3px 7px 0px,
425
+ rgba(0, 0, 0, 0.2) 0px 1px 2px 0px;
426
+ border: 1px solid #f3f3f3;
427
+ opacity: 0;
428
+ transform: scale(0.8);
429
+ transition: opacity 0.2s ease-out, transform 0.2s ease-out;
430
+ z-index: 2;
431
+ }
432
+
433
+ .popup .arrow {
434
+ z-index: 1;
435
+ text-shadow: 0px 3px 3px rgba(0, 0, 0, 0.1);
436
+ position: absolute;
437
+ justify-content: center;
438
+ text-align: center;
439
+ font-size: 1.3em;
440
+ transform: translateY(0.7em) scale(1);
441
+ color: #fff;
442
+ bottom: 0;
443
+ }
444
+
445
+ .bubble-wrap:hover .popup {
446
+ transform: translateY(-100%);
447
+ margin-top: -0.5em;
448
+ opacity: 1;
449
+ }
450
+ `;
451
+ }
452
+
453
+ @property({ type: Array })
454
+ messageGroups: string[][] = [];
455
+
456
+ @property({ type: Boolean })
457
+ fetching = false;
458
+
459
+ @property({ type: Boolean, attribute: false })
460
+ hideTopScroll = true;
461
+
462
+ @property({ type: Boolean, attribute: false })
463
+ hideBottomScroll = true;
464
+
465
+ @property({ type: String, attribute: 'avatar' })
466
+ defaultAvatar = DEFAULT_AVATAR;
467
+
468
+ @property({ type: Boolean })
469
+ agent = false;
470
+
471
+ private msgMap = new Map<string, ChatEvent>();
472
+
473
+ public firstUpdated(
474
+ changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
475
+ ): void {
476
+ super.firstUpdated(changed);
477
+ const scroll = this.shadowRoot.querySelector('.scroll');
478
+ const hasScroll = scroll.scrollHeight > scroll.clientHeight;
479
+ this.hideBottomScroll = true;
480
+ this.hideTopScroll = !hasScroll;
481
+ }
482
+
483
+ public addMessages(
484
+ messages: ChatEvent[],
485
+ startTime: Date = null,
486
+ append = false
487
+ ) {
488
+ // make sure our messages have ids
489
+ messages.forEach((m) => {
490
+ if (!m.id) {
491
+ m.id = hashCode(m.text) + '_' + m.date.toISOString();
492
+ }
493
+ });
494
+
495
+ if (!startTime) {
496
+ startTime = new Date();
497
+ }
498
+
499
+ const elapsed = new Date().getTime() - startTime.getTime();
500
+ window.setTimeout(
501
+ () => {
502
+ this.fetching = false;
503
+ // first add messages to the map
504
+ const newMessages = [];
505
+ for (const m of messages) {
506
+ if (this.addMessage(m)) {
507
+ newMessages.push(m.id);
508
+ }
509
+ }
510
+
511
+ if (newMessages.length === 0) {
512
+ return;
513
+ }
514
+
515
+ const ele = this.shadowRoot.querySelector('.scroll');
516
+ const prevTop = ele.scrollTop;
517
+
518
+ const grouped = this.groupMessages(newMessages);
519
+ this.insertGroups(grouped, append);
520
+
521
+ window.setTimeout(() => {
522
+ ele.scrollTop = prevTop;
523
+
524
+ this.fireCustomEvent(CustomEventType.FetchComplete);
525
+ }, 100);
526
+ },
527
+ // if it's the first load don't wait, otherwise wait a minimum amount of time
528
+ this.messageGroups.length === 0
529
+ ? 0
530
+ : Math.max(0, MIN_FETCH_TIME - elapsed)
531
+ );
532
+ }
533
+
534
+ private addMessage(msg: ChatEvent): boolean {
535
+ const isNew = !this.messageExists(msg);
536
+ this.msgMap.set(msg.id, msg);
537
+ return isNew;
538
+ }
539
+
540
+ public messageExists(msg: ChatEvent): boolean {
541
+ return this.msgMap.has(msg.id);
542
+ }
543
+
544
+ private isSameGroup(msg1: ChatEvent, msg2: ChatEvent): boolean {
545
+ if (msg1 && msg2) {
546
+ return (
547
+ msg1.type === msg2.type &&
548
+ msg1.user?.name === msg2.user?.name &&
549
+ Math.abs(msg1.date.getTime() - msg2.date.getTime()) < BATCH_TIME_WINDOW
550
+ );
551
+ }
552
+
553
+ return false;
554
+ }
555
+
556
+ private insertGroups(newGroups: string[][], append = false) {
557
+ if (!append) {
558
+ newGroups.reverse();
559
+ }
560
+
561
+ for (const newGroup of newGroups) {
562
+ // see if our new group belongs to the most recent group
563
+ const group =
564
+ this.messageGroups[append ? 0 : this.messageGroups.length - 1];
565
+
566
+ if (group) {
567
+ const lastMsgId = group[group.length - 1];
568
+ const lastMsg = this.msgMap.get(lastMsgId);
569
+ const newMsg = this.msgMap.get(newGroup[0]);
570
+ // if our message belongs to the previous group, in we go
571
+ if (this.isSameGroup(lastMsg, newMsg)) {
572
+ group.push(...newGroup);
573
+ } else {
574
+ // otherwise, just add our entire group as a new one
575
+ if (append) {
576
+ this.messageGroups.splice(0, 0, newGroup);
577
+ } else {
578
+ this.messageGroups.push(newGroup);
579
+ }
580
+ }
581
+ } else {
582
+ if (append) {
583
+ this.messageGroups.splice(0, 0, newGroup);
584
+ } else {
585
+ this.messageGroups.push(newGroup);
586
+ }
587
+ }
588
+ }
589
+
590
+ this.requestUpdate('messageGroups');
591
+ }
592
+
593
+ private groupMessages(msgIds: string[]): string[][] {
594
+ // group our messages by origin and user
595
+ const groups = [];
596
+ let lastGroup = [];
597
+ let lastMsg = null;
598
+ for (const msgId of msgIds) {
599
+ const msg = this.msgMap.get(msgId);
600
+ if (!this.isSameGroup(msg, lastMsg)) {
601
+ lastGroup = [];
602
+ groups.push(lastGroup);
603
+ }
604
+ lastGroup.push(msgId);
605
+ lastMsg = msg;
606
+ }
607
+ return groups;
608
+ }
609
+
610
+ private handleScroll(event: any) {
611
+ const ele = event.target;
612
+ const top = ele.scrollHeight - ele.clientHeight;
613
+ const scroll = Math.round(top + ele.scrollTop);
614
+ const scrollPct = scroll / top;
615
+
616
+ this.hideTopScroll = scrollPct <= 0.01;
617
+ this.hideBottomScroll = scrollPct >= 0.99;
618
+
619
+ if (scrollPct < SCROLL_FETCH_BUFFER) {
620
+ this.fireCustomEvent(CustomEventType.ScrollThreshold);
621
+ }
622
+ }
623
+
624
+ private scrollToBottom() {
625
+ const scroll = this.shadowRoot.querySelector('.scroll');
626
+ if (scroll) {
627
+ scroll.scrollTop = scroll.scrollHeight;
628
+ this.hideBottomScroll = true;
629
+ }
630
+ }
631
+
632
+ private renderMessageGroup(
633
+ msgIds: string[],
634
+ idx: number,
635
+ groups: string[][]
636
+ ): TemplateResult {
637
+ const today = new Date();
638
+ const firstGroup = idx === groups.length - 1;
639
+
640
+ let prevMsg: ChatEvent;
641
+ if (idx > 0) {
642
+ const lastGroup = groups[idx - 1];
643
+ if (lastGroup && lastGroup.length > 0) {
644
+ prevMsg = this.msgMap.get(lastGroup[0]);
645
+ }
646
+ }
647
+
648
+ const mostRecentId = msgIds[msgIds.length - 1];
649
+ const currentMsg = this.msgMap.get(mostRecentId);
650
+ let timeDisplay = null;
651
+ if (
652
+ prevMsg &&
653
+ !this.isSameGroup(prevMsg, currentMsg) &&
654
+ (Math.abs(currentMsg.date.getTime() - prevMsg.date.getTime()) >
655
+ BATCH_TIME_WINDOW ||
656
+ idx === groups.length - 1)
657
+ ) {
658
+ if (
659
+ today.getDate() !== prevMsg.date.getDate() ||
660
+ prevMsg.date.getDate() !== currentMsg.date.getDate()
661
+ ) {
662
+ timeDisplay = html`<div class="time ${firstGroup ? 'first' : ''}">
663
+ ${prevMsg.date.toLocaleTimeString(undefined, VERBOSE_FORMAT)}
664
+ </div>`;
665
+ } else {
666
+ timeDisplay = html`<div class="time ${firstGroup ? 'first' : ''}">
667
+ ${prevMsg.date.toLocaleTimeString(undefined, TIME_FORMAT)}
668
+ </div>`;
669
+ }
670
+ }
671
+
672
+ const incoming = this.agent
673
+ ? currentMsg.type !== 'msg_in'
674
+ : currentMsg.type === 'msg_in';
675
+ const name = currentMsg.user?.name;
676
+ let avatar = currentMsg.user?.avatar;
677
+
678
+ if (!currentMsg.user) {
679
+ avatar = this.defaultAvatar;
680
+ }
681
+
682
+ let showAvatar =
683
+ ((currentMsg.type === 'note' ||
684
+ currentMsg.type === 'msg_in' ||
685
+ currentMsg.type === 'msg_out') &&
686
+ this.agent) ||
687
+ !incoming;
688
+
689
+ // if we don't have a name or avatar, skip it
690
+ showAvatar = showAvatar && (!!avatar || !!name);
691
+
692
+ return html`
693
+ ${!firstGroup ? timeDisplay : null}
694
+ <div
695
+ class="block ${incoming ? 'incoming' : 'outgoing'} ${currentMsg.type}"
696
+ >
697
+ ${msgIds.slice(0, msgIds.length - 1).map((msgId, index) => {
698
+ const msg = this.msgMap.get(msgId);
699
+ return html`<div class="row message">
700
+ ${showAvatar ? html`<div class="avatar"></div>` : null}
701
+ ${this.renderMessage(msg, index == 0 ? name : null)}
702
+ </div>`;
703
+ })}
704
+ <div class="row latest message">
705
+ ${showAvatar
706
+ ? html`<div class="avatar">
707
+ ${renderAvatar({ name: name, user: { avatar: avatar } })}
708
+ </div>`
709
+ : null}
710
+ ${this.renderMessage(
711
+ currentMsg,
712
+ showAvatar && msgIds.length === 1 ? name : null
713
+ )}
714
+ </div>
715
+ </div>
716
+ ${firstGroup ? timeDisplay : null}
717
+ `;
718
+ }
719
+
720
+ private renderMessage(event: ChatEvent, name = null): TemplateResult {
721
+ if (
722
+ event.type === MessageType.Error ||
723
+ event.type === MessageType.Collapse ||
724
+ event.type === MessageType.Inline
725
+ ) {
726
+ return html`<div class="event">${renderMarkdown(event.text)}</div>`;
727
+ }
728
+
729
+ const message = event as Message;
730
+ return html`
731
+ <div class="bubble-wrap ${message.sendError ? 'error' : ''}">
732
+ ${
733
+ message.popup
734
+ ? html`<div class="popup">
735
+ ${message.popup}
736
+ <div class="arrow">▼</div>
737
+ </div>`
738
+ : null
739
+ }
740
+
741
+ ${
742
+ message.text
743
+ ? html`
744
+ <div class="bubble">
745
+ ${name ? html`<div class="name">${name}</div>` : null}
746
+ <div class="message">${message.text}</div>
747
+
748
+ <!--div>${message.date.toLocaleDateString(
749
+ undefined,
750
+ VERBOSE_FORMAT
751
+ )}</div-->
752
+ </div>
753
+ `
754
+ : null
755
+ }
756
+
757
+ <div class="attachments">
758
+ ${(message.attachments || []).map(
759
+ (attachment) =>
760
+ html`<temba-thumbnail
761
+ attachment="${attachment}"
762
+ ></temba-thumbnail>`
763
+ )}
764
+ </div>
765
+ </div>
766
+ </div>
767
+ `;
768
+ }
769
+
770
+ public render(): TemplateResult {
771
+ return html` <div
772
+ class="
773
+ messages
774
+ ${this.hideBottomScroll ? 'scroll-at-bottom' : ''}
775
+ ${this.hideTopScroll ? 'scroll-at-top' : ''}"
776
+ >
777
+ <div class="scroll" @scroll=${this.handleScroll}>
778
+ ${this.messageGroups
779
+ ? this.messageGroups.map(
780
+ (msgGroup, idx, groups) =>
781
+ html`${this.renderMessageGroup(msgGroup, idx, groups)}`
782
+ )
783
+ : null}
784
+
785
+ <temba-loading
786
+ class="${!this.fetching ? 'hidden' : ''}"
787
+ ></temba-loading>
788
+ </div>
789
+ </div>`;
790
+ }
791
+ }