@nyaruka/temba-components 0.167.0 → 0.168.1
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 +27 -0
- package/dist/locales/es.js +3 -1
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +3 -1
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +3 -1
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +2842 -1715
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/Icons.ts +2 -0
- package/src/display/Button.ts +8 -6
- package/src/display/Chat.ts +541 -191
- package/src/display/Dropdown.ts +40 -17
- package/src/display/Label.ts +25 -4
- package/src/display/TembaDate.ts +11 -2
- package/src/display/Tip.ts +157 -18
- package/src/events/eventRenderers.ts +678 -268
- package/src/events.ts +16 -1
- package/src/flow/NodeEditor.ts +82 -9
- package/src/flow/actions/add_contact_urn.ts +1 -0
- package/src/flow/actions/play_audio.ts +1 -0
- package/src/flow/actions/send_broadcast.ts +17 -1
- package/src/flow/actions/send_msg.ts +12 -1
- package/src/flow/actions/set_run_result.ts +1 -0
- package/src/flow/actions/start_session.ts +10 -1
- package/src/flow/nodes/shared-rules.ts +2 -0
- package/src/flow/nodes/split_by_airtime.ts +6 -0
- package/src/flow/nodes/split_by_expression.ts +1 -0
- package/src/flow/nodes/split_by_llm.ts +2 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
- package/src/flow/nodes/split_by_random.ts +2 -1
- package/src/flow/nodes/split_by_resthook.ts +4 -1
- package/src/flow/nodes/split_by_webhook.ts +9 -1
- package/src/flow/types.ts +3 -0
- package/src/flow/utils.ts +49 -0
- package/src/form/DatePicker.ts +2 -1
- package/src/layout/Card.ts +9 -0
- package/src/layout/CardLayout.ts +426 -21
- package/src/layout/CardStack.ts +4 -1
- package/src/list/ContactList.ts +24 -17
- package/src/list/ContentList.ts +640 -33
- package/src/list/MsgList.ts +7 -1
- package/src/list/SortableList.ts +5 -1
- package/src/list/TembaMenu.ts +67 -6
- package/src/live/ContactChat.ts +655 -336
- package/src/live/ContactDetails.ts +961 -88
- package/src/live/ContactFieldEditor.ts +46 -11
- package/src/live/ContactFields.ts +57 -16
- package/src/live/ContactStoreElement.ts +15 -8
- package/src/locales/es.ts +3 -2
- package/src/locales/fr.ts +3 -2
- package/src/locales/pt.ts +3 -2
- package/src/simulator/Simulator.ts +28 -0
- package/src/styles/pillVariants.ts +13 -1
- package/web-test-runner.config.mjs +2 -0
- package/xliff/es.xlf +6 -3
- package/xliff/fr.xlf +6 -3
- package/xliff/pt.xlf +6 -3
package/src/display/Chat.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { repeat } from 'lit/directives/repeat.js';
|
|
|
4
4
|
import { RapidElement } from '../RapidElement';
|
|
5
5
|
import { CustomEventType } from '../interfaces';
|
|
6
6
|
import { TicketEvent } from '../events';
|
|
7
|
+
import { renderEventSummary } from '../events/eventRenderers';
|
|
7
8
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
8
9
|
import { attachmentAsString } from '../utils';
|
|
10
|
+
import { DateTime } from 'luxon';
|
|
9
11
|
|
|
10
|
-
const BATCH_TIME_WINDOW = 60 * 60 * 1000;
|
|
11
12
|
const SCROLL_FETCH_BUFFER = 200; // pixels from top
|
|
12
13
|
const MIN_FETCH_TIME = 250;
|
|
13
14
|
|
|
@@ -141,6 +142,45 @@ const getTypingKey = (event: TypingEvent): string => {
|
|
|
141
142
|
return event._user?.uuid || 'contact';
|
|
142
143
|
};
|
|
143
144
|
|
|
145
|
+
// event types whose inline rendering is a self-contained pill (see
|
|
146
|
+
// eventRenderers.ts) — consecutive runs of these condense onto a
|
|
147
|
+
// single wrapping line instead of one per line. Diagnostics
|
|
148
|
+
// (error/warning/failure) and note bubbles stay on their own lines.
|
|
149
|
+
const CONDENSED_EVENT_TYPES = new Set([
|
|
150
|
+
'airtime_created',
|
|
151
|
+
'airtime_transferred',
|
|
152
|
+
'broadcast_created',
|
|
153
|
+
'call_created',
|
|
154
|
+
'call_missed',
|
|
155
|
+
'call_received',
|
|
156
|
+
'chat_started',
|
|
157
|
+
'contact_field_changed',
|
|
158
|
+
'contact_groups_changed',
|
|
159
|
+
'contact_language_changed',
|
|
160
|
+
'contact_name_changed',
|
|
161
|
+
'contact_status_changed',
|
|
162
|
+
'contact_urns_changed',
|
|
163
|
+
'email_created',
|
|
164
|
+
'email_sent',
|
|
165
|
+
'flow_entered',
|
|
166
|
+
'input_labels_added',
|
|
167
|
+
'optin_requested',
|
|
168
|
+
'optin_started',
|
|
169
|
+
'optin_stopped',
|
|
170
|
+
'resthook_called',
|
|
171
|
+
'run_ended',
|
|
172
|
+
'run_result_changed',
|
|
173
|
+
'run_started',
|
|
174
|
+
'service_called',
|
|
175
|
+
'session_triggered',
|
|
176
|
+
'ticket_assignee_changed',
|
|
177
|
+
'ticket_closed',
|
|
178
|
+
'ticket_opened',
|
|
179
|
+
'ticket_reopened',
|
|
180
|
+
'ticket_topic_changed',
|
|
181
|
+
'webhook_called'
|
|
182
|
+
]);
|
|
183
|
+
|
|
144
184
|
// whether a message has nothing to show in its bubble (and isn't deleted)
|
|
145
185
|
const isEmptyMsg = (event: MsgEvent): boolean =>
|
|
146
186
|
!event._deleted &&
|
|
@@ -148,23 +188,34 @@ const isEmptyMsg = (event: MsgEvent): boolean =>
|
|
|
148
188
|
!event.msg.text &&
|
|
149
189
|
(event.msg.attachments || []).length === 0;
|
|
150
190
|
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
191
|
+
// local-time calendar day, used to bucket messages into day sections
|
|
192
|
+
const getDayKey = (date: Date): string =>
|
|
193
|
+
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Human label for a day section: Today, Yesterday, "Jun 22" within
|
|
197
|
+
* the current year, and a full "9/1/2025" for anything older.
|
|
198
|
+
* "Now" comes from luxon so it honors the same clock the rest of the
|
|
199
|
+
* date rendering uses (and its test stubs).
|
|
200
|
+
*/
|
|
201
|
+
const getDayLabel = (date: Date): string => {
|
|
202
|
+
const today = DateTime.now().toJSDate();
|
|
203
|
+
if (getDayKey(date) === getDayKey(today)) {
|
|
204
|
+
return 'Today';
|
|
205
|
+
}
|
|
206
|
+
const yesterday = new Date(today);
|
|
207
|
+
yesterday.setDate(today.getDate() - 1);
|
|
208
|
+
if (getDayKey(date) === getDayKey(yesterday)) {
|
|
209
|
+
return 'Yesterday';
|
|
210
|
+
}
|
|
211
|
+
if (date.getFullYear() === today.getFullYear()) {
|
|
212
|
+
return date.toLocaleDateString(undefined, {
|
|
213
|
+
month: 'short',
|
|
214
|
+
day: 'numeric'
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return date.toLocaleDateString();
|
|
218
|
+
};
|
|
168
219
|
|
|
169
220
|
export class Chat extends RapidElement {
|
|
170
221
|
static get styles() {
|
|
@@ -223,29 +274,44 @@ export class Chat extends RapidElement {
|
|
|
223
274
|
line-height: 0;
|
|
224
275
|
}
|
|
225
276
|
|
|
226
|
-
.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
margin-left: 10%;
|
|
235
|
-
margin-right: 10%;
|
|
277
|
+
/* Each calendar day of history renders as its own section. The
|
|
278
|
+
section itself is normal top-down flow (its contents are
|
|
279
|
+
ordered oldest-first at render time) — position: sticky
|
|
280
|
+
misbehaves inside column-reverse, so only the scroller
|
|
281
|
+
reverses. */
|
|
282
|
+
.day-section {
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: column;
|
|
236
285
|
}
|
|
237
286
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
287
|
+
/* The day marker pins to the top of the chat window while its
|
|
288
|
+
day's messages scroll underneath — position: sticky keeps it
|
|
289
|
+
floating until the section ends, at which point the next
|
|
290
|
+
day's marker takes over. pointer-events pass through so the
|
|
291
|
+
chip never blocks interaction with messages behind it. */
|
|
292
|
+
.day-marker {
|
|
293
|
+
position: sticky;
|
|
294
|
+
top: 0;
|
|
295
|
+
z-index: 3;
|
|
296
|
+
display: flex;
|
|
297
|
+
justify-content: center;
|
|
298
|
+
padding: 0.25em 0 0.75em;
|
|
299
|
+
pointer-events: none;
|
|
243
300
|
}
|
|
244
301
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
302
|
+
/* Translucent chip so messages read through it while it floats
|
|
303
|
+
over them mid-scroll. */
|
|
304
|
+
.day-marker .day {
|
|
305
|
+
font-size: 0.7em;
|
|
306
|
+
font-weight: 500;
|
|
307
|
+
letter-spacing: 0.03em;
|
|
308
|
+
color: #71767d;
|
|
309
|
+
background: rgba(255, 255, 255, 0.85);
|
|
310
|
+
backdrop-filter: blur(6px);
|
|
311
|
+
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
312
|
+
border-radius: 999px;
|
|
313
|
+
padding: 3px 12px;
|
|
314
|
+
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
|
249
315
|
}
|
|
250
316
|
|
|
251
317
|
.group-reason {
|
|
@@ -271,6 +337,142 @@ export class Chat extends RapidElement {
|
|
|
271
337
|
margin-right: 4px !important;
|
|
272
338
|
}
|
|
273
339
|
|
|
340
|
+
/* An expanded run of self-contained pill events (field changes,
|
|
341
|
+
group changes, etc. — see CONDENSED_EVENT_TYPES): a collapse
|
|
342
|
+
rail down the left, then the pills flowing as a wrapping,
|
|
343
|
+
centered row. Extra specificity keeps the .incoming .row
|
|
344
|
+
row-reverse rule below from flipping the rail to the right. */
|
|
345
|
+
.group-messages .row.condensed-events {
|
|
346
|
+
flex-direction: row;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* Expanding animates via the grid 0fr -> 1fr technique so the
|
|
350
|
+
run grows smoothly from the summary's height without any
|
|
351
|
+
measured heights; collapsing plays the same animation in
|
|
352
|
+
reverse (forwards) while the run is in the .collapsing state,
|
|
353
|
+
then the render swaps in the summary pill. */
|
|
354
|
+
.condensed-events .reveal {
|
|
355
|
+
flex-grow: 1;
|
|
356
|
+
min-width: 0;
|
|
357
|
+
display: grid;
|
|
358
|
+
grid-template-rows: 1fr;
|
|
359
|
+
animation: reveal-expand 200ms ease;
|
|
360
|
+
/* floor both animations at the summary pill's height so the
|
|
361
|
+
swap between summary and run happens with no net height
|
|
362
|
+
change — without this the run shrinks to zero and the
|
|
363
|
+
summary pops back up, reading as a false bounce */
|
|
364
|
+
min-height: 24px;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.condensed-events.collapsing .reveal {
|
|
368
|
+
animation: reveal-collapse 180ms ease forwards;
|
|
369
|
+
pointer-events: none;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.condensed-events .reveal-inner {
|
|
373
|
+
overflow: hidden;
|
|
374
|
+
min-width: 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.condensed-events .run {
|
|
378
|
+
display: flex;
|
|
379
|
+
flex-direction: row;
|
|
380
|
+
flex-wrap: nowrap;
|
|
381
|
+
align-items: stretch;
|
|
382
|
+
gap: 4px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
@keyframes reveal-expand {
|
|
386
|
+
from {
|
|
387
|
+
grid-template-rows: 0fr;
|
|
388
|
+
opacity: 0;
|
|
389
|
+
}
|
|
390
|
+
to {
|
|
391
|
+
grid-template-rows: 1fr;
|
|
392
|
+
opacity: 1;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
@keyframes reveal-collapse {
|
|
397
|
+
from {
|
|
398
|
+
grid-template-rows: 1fr;
|
|
399
|
+
opacity: 1;
|
|
400
|
+
}
|
|
401
|
+
to {
|
|
402
|
+
grid-template-rows: 0fr;
|
|
403
|
+
opacity: 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* the summary pill eases back in after a collapse (and on first
|
|
408
|
+
render — it's subtle enough to double as a gentle entrance) */
|
|
409
|
+
.row.summary-row {
|
|
410
|
+
animation: summary-in 150ms ease;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@keyframes summary-in {
|
|
414
|
+
from {
|
|
415
|
+
opacity: 0;
|
|
416
|
+
transform: translateY(2px);
|
|
417
|
+
}
|
|
418
|
+
to {
|
|
419
|
+
opacity: 1;
|
|
420
|
+
transform: none;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@media (prefers-reduced-motion: reduce) {
|
|
425
|
+
.condensed-events .reveal,
|
|
426
|
+
.condensed-events.collapsing .reveal,
|
|
427
|
+
.row.summary-row {
|
|
428
|
+
animation: none;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.condensed-events .events {
|
|
433
|
+
flex-grow: 1;
|
|
434
|
+
min-width: 0;
|
|
435
|
+
display: flex;
|
|
436
|
+
flex-wrap: wrap;
|
|
437
|
+
justify-content: center;
|
|
438
|
+
align-items: center;
|
|
439
|
+
column-gap: 4px;
|
|
440
|
+
row-gap: 2px;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.condensed-events .event {
|
|
444
|
+
flex-grow: 0;
|
|
445
|
+
max-width: 100%;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* The rail is the collapse affordance: an icon button at the
|
|
449
|
+
top with a thin line running the height of the run. The whole
|
|
450
|
+
rail is clickable, quietly highlighting on hover. */
|
|
451
|
+
.collapse-rail {
|
|
452
|
+
display: flex;
|
|
453
|
+
flex-direction: column;
|
|
454
|
+
align-items: center;
|
|
455
|
+
gap: 3px;
|
|
456
|
+
padding: 1px 2px;
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
--icon-color: #9aa1a9;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.collapse-rail .rail-line {
|
|
462
|
+
flex-grow: 1;
|
|
463
|
+
width: 2px;
|
|
464
|
+
border-radius: 2px;
|
|
465
|
+
background: #e4e7ea;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.collapse-rail:hover {
|
|
469
|
+
--icon-color: #6b7280;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.collapse-rail:hover .rail-line {
|
|
473
|
+
background: #c9cdd2;
|
|
474
|
+
}
|
|
475
|
+
|
|
274
476
|
.input-panel {
|
|
275
477
|
padding: 1em;
|
|
276
478
|
background: #fff;
|
|
@@ -312,11 +514,24 @@ export class Chat extends RapidElement {
|
|
|
312
514
|
margin-left: 1em;
|
|
313
515
|
}
|
|
314
516
|
|
|
517
|
+
/* Bubble fills are soft top-lit gradients derived from the
|
|
518
|
+
same theme vars the flat fills used — color-mix keeps host
|
|
519
|
+
re-theming working — with a whisper of shadow for depth and
|
|
520
|
+
a hairline border mixed just darker than the fill so bubbles
|
|
521
|
+
hold their edge against the background. */
|
|
315
522
|
.bubble {
|
|
316
523
|
padding: 10px 14px;
|
|
317
|
-
background:
|
|
524
|
+
background: linear-gradient(
|
|
525
|
+
180deg,
|
|
526
|
+
color-mix(in srgb, var(--color-chat-in, #e5e5ea) 62%, white) 0%,
|
|
527
|
+
var(--color-chat-in, #e5e5ea) 100%
|
|
528
|
+
);
|
|
318
529
|
border-radius: 18px;
|
|
319
|
-
border: var(
|
|
530
|
+
border: var(
|
|
531
|
+
--chat-border-in,
|
|
532
|
+
1px solid color-mix(in srgb, var(--color-chat-in, #e5e5ea) 93%, black)
|
|
533
|
+
);
|
|
534
|
+
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
|
320
535
|
}
|
|
321
536
|
|
|
322
537
|
.bubble .name {
|
|
@@ -335,8 +550,17 @@ export class Chat extends RapidElement {
|
|
|
335
550
|
}
|
|
336
551
|
|
|
337
552
|
.incoming .bubble {
|
|
338
|
-
background:
|
|
339
|
-
|
|
553
|
+
background: linear-gradient(
|
|
554
|
+
180deg,
|
|
555
|
+
color-mix(in srgb, var(--color-chat-out, #007aff) 90%, white) 0%,
|
|
556
|
+
var(--color-chat-out, #007aff) 58%,
|
|
557
|
+
color-mix(in srgb, var(--color-chat-out, #007aff) 95%, black) 100%
|
|
558
|
+
);
|
|
559
|
+
border: var(
|
|
560
|
+
--chat-border-out,
|
|
561
|
+
1px solid
|
|
562
|
+
color-mix(in srgb, var(--color-chat-out, #007aff) 90%, black)
|
|
563
|
+
);
|
|
340
564
|
color: white;
|
|
341
565
|
}
|
|
342
566
|
|
|
@@ -348,8 +572,14 @@ export class Chat extends RapidElement {
|
|
|
348
572
|
color: rgba(255, 255, 255, 0.7);
|
|
349
573
|
}
|
|
350
574
|
|
|
575
|
+
/* Notes get the same top-lit gradient treatment as message
|
|
576
|
+
bubbles, derived from their sticky-note yellow. */
|
|
351
577
|
.note .bubble {
|
|
352
|
-
background:
|
|
578
|
+
background: linear-gradient(
|
|
579
|
+
180deg,
|
|
580
|
+
color-mix(in srgb, #fffac3 62%, white) 0%,
|
|
581
|
+
#fffac3 100%
|
|
582
|
+
);
|
|
353
583
|
border: 1px solid #e8d169;
|
|
354
584
|
color: #5d4e1e;
|
|
355
585
|
}
|
|
@@ -358,12 +588,6 @@ export class Chat extends RapidElement {
|
|
|
358
588
|
color: rgba(93, 78, 30, 0.65);
|
|
359
589
|
}
|
|
360
590
|
|
|
361
|
-
.note-time {
|
|
362
|
-
margin-top: 0.35em;
|
|
363
|
-
font-size: 0.9em;
|
|
364
|
-
color: rgba(93, 78, 30, 0.65);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
591
|
.warning .bubble {
|
|
368
592
|
background: #fef3c7;
|
|
369
593
|
color: rgba(125, 87, 18, 0.8);
|
|
@@ -761,35 +985,82 @@ export class Chat extends RapidElement {
|
|
|
761
985
|
|
|
762
986
|
.popup {
|
|
763
987
|
display: flex;
|
|
988
|
+
align-items: center;
|
|
764
989
|
position: absolute;
|
|
765
990
|
background: #fff;
|
|
766
991
|
margin: 0;
|
|
767
|
-
|
|
992
|
+
/* same treatment as the metadata line in the event tooltips */
|
|
993
|
+
font-size: 11px;
|
|
994
|
+
padding: 6px 10px;
|
|
768
995
|
border-radius: var(--curvature);
|
|
769
996
|
box-shadow:
|
|
770
997
|
rgba(0, 0, 0, 0.05) 0px 3px 7px 0px,
|
|
771
998
|
rgba(0, 0, 0, 0.2) 0px 1px 2px 0px;
|
|
772
999
|
border: 1px solid #f3f3f3;
|
|
773
1000
|
opacity: 0;
|
|
1001
|
+
visibility: hidden;
|
|
1002
|
+
/* sit a small gap above the bubble regardless of popup height
|
|
1003
|
+
so the arrow bridges it, matching the event tooltips — the
|
|
1004
|
+
hoverable linger makes the gap crossable */
|
|
1005
|
+
bottom: calc(100% + 6px);
|
|
1006
|
+
transform: translateY(6px);
|
|
774
1007
|
z-index: 2;
|
|
1008
|
+
/* linger briefly on unhover so the mouse can travel into the
|
|
1009
|
+
popup to select or click its contents, then slide back down
|
|
1010
|
+
while fading; visibility flips once the fade finishes and
|
|
1011
|
+
keeps the hidden popup from swallowing mouse events over
|
|
1012
|
+
the bubble */
|
|
1013
|
+
transition:
|
|
1014
|
+
opacity 120ms cubic-bezier(0.2, 0, 0, 1) 300ms,
|
|
1015
|
+
transform 120ms cubic-bezier(0.2, 0, 0, 1) 300ms,
|
|
1016
|
+
visibility 0s linear 420ms;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.popup .log-link {
|
|
1020
|
+
margin-left: 1em;
|
|
1021
|
+
color: #999;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.popup .log-link:hover {
|
|
1025
|
+
color: var(--color-primary-dark);
|
|
775
1026
|
}
|
|
776
1027
|
|
|
777
1028
|
.popup .arrow {
|
|
778
1029
|
z-index: 1;
|
|
779
|
-
text-shadow: 0px 3px 3px rgba(0, 0, 0, 0.1);
|
|
780
1030
|
position: absolute;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1031
|
+
bottom: -9px;
|
|
1032
|
+
width: 16px;
|
|
1033
|
+
height: 11px;
|
|
1034
|
+
/* the shadow is on this wrapper rather than the clipped
|
|
1035
|
+
shape below so it hugs the triangle's slanted edges */
|
|
1036
|
+
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.12));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/* a CSS-drawn tail rather than a ▼ glyph — glyph ink lands
|
|
1040
|
+
wherever the platform font puts it, so the tip rendered
|
|
1041
|
+
hidden on some systems and overshot on others. This triangle
|
|
1042
|
+
is deterministic: its top 2px lie on the popup body hiding
|
|
1043
|
+
the border where the tail meets it, the rest bridges the gap
|
|
1044
|
+
below so the tip visibly laps over the top of the bubble */
|
|
1045
|
+
.popup .arrow::after {
|
|
1046
|
+
content: '';
|
|
1047
|
+
display: block;
|
|
1048
|
+
width: 100%;
|
|
1049
|
+
height: 100%;
|
|
1050
|
+
background: #fff;
|
|
1051
|
+
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
|
787
1052
|
}
|
|
788
1053
|
|
|
789
1054
|
.bubble-wrap:hover .popup {
|
|
790
1055
|
opacity: 1;
|
|
791
|
-
|
|
792
|
-
|
|
1056
|
+
visibility: visible;
|
|
1057
|
+
transform: translateY(0);
|
|
1058
|
+
/* same 350ms hover-intent delay as the event pill tips, then
|
|
1059
|
+
slide up into position */
|
|
1060
|
+
transition:
|
|
1061
|
+
opacity 120ms cubic-bezier(0.2, 0, 0, 1) 350ms,
|
|
1062
|
+
transform 120ms cubic-bezier(0.2, 0, 0, 1) 350ms,
|
|
1063
|
+
visibility 0s linear 350ms;
|
|
793
1064
|
}
|
|
794
1065
|
|
|
795
1066
|
.new-message-notification {
|
|
@@ -911,6 +1182,17 @@ export class Chat extends RapidElement {
|
|
|
911
1182
|
highlightMessageUuid: string = null;
|
|
912
1183
|
|
|
913
1184
|
private msgMap = new Map<string, ContactEvent>();
|
|
1185
|
+
|
|
1186
|
+
// runs of informational events collapse behind a summary pill —
|
|
1187
|
+
// this tracks which runs the user has expanded as a set of event
|
|
1188
|
+
// ids (a run is expanded if any of its events is present). keying
|
|
1189
|
+
// on membership rather than the run's first id keeps expansion
|
|
1190
|
+
// stable when pagination prepends older events into the run
|
|
1191
|
+
private expandedEventChunks = new Set<string>();
|
|
1192
|
+
|
|
1193
|
+
// runs mid-collapse: they keep rendering expanded (with the closing
|
|
1194
|
+
// animation) until the animation finishes, then swap to the summary
|
|
1195
|
+
private collapsingEventChunks = new Set<string>();
|
|
914
1196
|
private metadataCache = new Map<string, ContactEvent>();
|
|
915
1197
|
|
|
916
1198
|
// ephemeral typing indicators by author key - they live in msgMap and
|
|
@@ -979,6 +1261,12 @@ export class Chat extends RapidElement {
|
|
|
979
1261
|
}
|
|
980
1262
|
|
|
981
1263
|
if (newMessages.length === 0) {
|
|
1264
|
+
// nothing rendered (a metadata-only or duplicate page) —
|
|
1265
|
+
// still settle the fetch and keep the chain going, since an
|
|
1266
|
+
// unscrollable view has no other way to ask for the rest of
|
|
1267
|
+
// history
|
|
1268
|
+
this.fireCustomEvent(CustomEventType.FetchComplete);
|
|
1269
|
+
this.requestMoreIfUnscrollable();
|
|
982
1270
|
return;
|
|
983
1271
|
}
|
|
984
1272
|
|
|
@@ -1036,6 +1324,7 @@ export class Chat extends RapidElement {
|
|
|
1036
1324
|
this.hideBottomScroll = absScrollTop <= 1;
|
|
1037
1325
|
|
|
1038
1326
|
this.fireCustomEvent(CustomEventType.FetchComplete);
|
|
1327
|
+
this.requestMoreIfUnscrollable();
|
|
1039
1328
|
}, 100);
|
|
1040
1329
|
},
|
|
1041
1330
|
// if it's the first load don't wait, otherwise wait a minimum amount of time
|
|
@@ -1191,8 +1480,7 @@ export class Chat extends RapidElement {
|
|
|
1191
1480
|
|
|
1192
1481
|
private isSameGroup(
|
|
1193
1482
|
msg1: ContactEvent,
|
|
1194
|
-
msg2: ContactEvent
|
|
1195
|
-
lastTimeElapsedDate?: Date
|
|
1483
|
+
msg2: ContactEvent
|
|
1196
1484
|
): { same: boolean; reason?: GroupReason } {
|
|
1197
1485
|
if (!msg1 || !msg2) {
|
|
1198
1486
|
return { same: true };
|
|
@@ -1215,14 +1503,12 @@ export class Chat extends RapidElement {
|
|
|
1215
1503
|
type2 === 'ticket_note_added';
|
|
1216
1504
|
const typeMatch = isMsg1 && isMsg2 ? type1 === type2 : isMsg1 === isMsg2;
|
|
1217
1505
|
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
BATCH_TIME_WINDOW
|
|
1225
|
-
) {
|
|
1506
|
+
// the only time boundary is the calendar day (the chat renders
|
|
1507
|
+
// day sections with sticky markers, so groups must align to
|
|
1508
|
+
// days) — within a day, hours apart doesn't split a group, so
|
|
1509
|
+
// e.g. a run expiry and a campaign start hours later still
|
|
1510
|
+
// condense with the events between them
|
|
1511
|
+
if (getDayKey(msg1.created_on) !== getDayKey(msg2.created_on)) {
|
|
1226
1512
|
return { same: false, reason: 'time_elapsed' };
|
|
1227
1513
|
}
|
|
1228
1514
|
|
|
@@ -1290,11 +1576,10 @@ export class Chat extends RapidElement {
|
|
|
1290
1576
|
const groups: MessageGroup[] = [];
|
|
1291
1577
|
let lastGroup: MessageGroup = null;
|
|
1292
1578
|
let lastMsg: ContactEvent = null;
|
|
1293
|
-
let lastTimeElapsedDate: Date = null;
|
|
1294
1579
|
|
|
1295
1580
|
for (const msgId of msgIds) {
|
|
1296
1581
|
const msg = this.msgMap.get(msgId);
|
|
1297
|
-
const groupCheck = this.isSameGroup(msg, lastMsg
|
|
1582
|
+
const groupCheck = this.isSameGroup(msg, lastMsg);
|
|
1298
1583
|
|
|
1299
1584
|
if (!groupCheck.same || !lastGroup) {
|
|
1300
1585
|
lastGroup = {
|
|
@@ -1302,11 +1587,6 @@ export class Chat extends RapidElement {
|
|
|
1302
1587
|
reason: groupCheck.reason || 'initial'
|
|
1303
1588
|
};
|
|
1304
1589
|
groups.push(lastGroup);
|
|
1305
|
-
|
|
1306
|
-
// track when we last broke for time_elapsed
|
|
1307
|
-
if (groupCheck.reason === 'time_elapsed') {
|
|
1308
|
-
lastTimeElapsedDate = msg.created_on;
|
|
1309
|
-
}
|
|
1310
1590
|
}
|
|
1311
1591
|
|
|
1312
1592
|
lastGroup.messages.push(msgId);
|
|
@@ -1315,6 +1595,22 @@ export class Chat extends RapidElement {
|
|
|
1315
1595
|
return groups;
|
|
1316
1596
|
}
|
|
1317
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* Older history is fetched from scroll position, but a fetched page
|
|
1600
|
+
* can render shorter than the viewport (condensed event runs
|
|
1601
|
+
* collapse a whole page into a line or two), leaving nothing to
|
|
1602
|
+
* scroll and no way to request more. After content settles, keep
|
|
1603
|
+
* asking for history until the view can meaningfully scroll — the
|
|
1604
|
+
* host's own guards (in-flight fetch, end of history) stop the
|
|
1605
|
+
* chain.
|
|
1606
|
+
*/
|
|
1607
|
+
private requestMoreIfUnscrollable() {
|
|
1608
|
+
const ele = this.shadowRoot.querySelector('.scroll');
|
|
1609
|
+
if (ele && ele.scrollHeight - ele.clientHeight <= SCROLL_FETCH_BUFFER) {
|
|
1610
|
+
this.fireCustomEvent(CustomEventType.ScrollThreshold);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1318
1614
|
private handleScroll(event: any) {
|
|
1319
1615
|
const ele = event.target;
|
|
1320
1616
|
const scrollableHeight = ele.scrollHeight - ele.clientHeight;
|
|
@@ -1422,12 +1718,7 @@ export class Chat extends RapidElement {
|
|
|
1422
1718
|
}
|
|
1423
1719
|
}
|
|
1424
1720
|
|
|
1425
|
-
private renderMessageGroup(
|
|
1426
|
-
group: MessageGroup,
|
|
1427
|
-
idx: number,
|
|
1428
|
-
lastShownTimestamp: Date | null
|
|
1429
|
-
): { html: TemplateResult; timestamp: Date | null } {
|
|
1430
|
-
const today = new Date();
|
|
1721
|
+
private renderMessageGroup(group: MessageGroup): TemplateResult {
|
|
1431
1722
|
const msgIds = group.messages;
|
|
1432
1723
|
|
|
1433
1724
|
const mostRecentId = msgIds[msgIds.length - 1];
|
|
@@ -1482,78 +1773,131 @@ export class Chat extends RapidElement {
|
|
|
1482
1773
|
const reasonLabel = this.getReasonLabel(group.reason);
|
|
1483
1774
|
const showReason = false; // reasonLabel && idx > 0;
|
|
1484
1775
|
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
const
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
const timeSinceLastShown = Math.abs(
|
|
1497
|
-
firstMsg.created_on.getTime() - lastShownTimestamp.getTime()
|
|
1498
|
-
);
|
|
1499
|
-
showTimeForReason = timeSinceLastShown >= BATCH_TIME_WINDOW;
|
|
1776
|
+
// chunk the group into runs — consecutive condensable pill events
|
|
1777
|
+
// share a single wrapping row, everything else renders one per row
|
|
1778
|
+
const chunks: { condensed: boolean; ids: string[]; start: number }[] = [];
|
|
1779
|
+
msgIds.forEach((msgId, index) => {
|
|
1780
|
+
const msg = this.msgMap.get(msgId);
|
|
1781
|
+
const condensed =
|
|
1782
|
+
msg?._rendered?.type === MessageType.Inline &&
|
|
1783
|
+
CONDENSED_EVENT_TYPES.has(msg.type);
|
|
1784
|
+
const last = chunks[chunks.length - 1];
|
|
1785
|
+
if (last && last.condensed && condensed) {
|
|
1786
|
+
last.ids.push(msgId);
|
|
1500
1787
|
} else {
|
|
1501
|
-
|
|
1788
|
+
chunks.push({ condensed, ids: [msgId], start: index });
|
|
1502
1789
|
}
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
let timeForReason = null;
|
|
1506
|
-
if (showTimeForReason) {
|
|
1507
|
-
newLastShownTimestamp = firstMsg.created_on;
|
|
1508
|
-
|
|
1509
|
-
const isDifferentYear =
|
|
1510
|
-
today.getFullYear() !== firstMsg.created_on.getFullYear();
|
|
1511
|
-
const format = isDifferentYear
|
|
1512
|
-
? VERBOSE_FORMAT_WITH_YEAR
|
|
1513
|
-
: today.getDate() !== firstMsg.created_on.getDate()
|
|
1514
|
-
? VERBOSE_FORMAT
|
|
1515
|
-
: TIME_FORMAT;
|
|
1516
|
-
|
|
1517
|
-
timeForReason = html`<div class="time time-elapsed">
|
|
1518
|
-
${firstMsg.created_on.toLocaleTimeString(undefined, format)}
|
|
1519
|
-
</div>`;
|
|
1520
|
-
}
|
|
1790
|
+
});
|
|
1521
1791
|
|
|
1522
1792
|
const resultHtml = html`
|
|
1523
1793
|
<div class="block ${incoming ? 'incoming' : 'outgoing'}">
|
|
1524
1794
|
<div class="group-messages" style="flex-grow:1">
|
|
1525
1795
|
${repeat(
|
|
1526
|
-
|
|
1527
|
-
(
|
|
1528
|
-
(
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
(
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1796
|
+
chunks,
|
|
1797
|
+
(chunk) => chunk.ids[0],
|
|
1798
|
+
(chunk) => {
|
|
1799
|
+
if (chunk.condensed) {
|
|
1800
|
+
// condensable events collapse behind a single summary
|
|
1801
|
+
// pill until expanded — even a run of one, so single
|
|
1802
|
+
// events behave the same as longer runs
|
|
1803
|
+
const expanded = chunk.ids.some((id) =>
|
|
1804
|
+
this.expandedEventChunks.has(id)
|
|
1805
|
+
);
|
|
1806
|
+
if (!expanded) {
|
|
1807
|
+
const events = chunk.ids.map((id) => this.msgMap.get(id));
|
|
1808
|
+
return html`<div class="row message is-event summary-row">
|
|
1809
|
+
<div class="event">
|
|
1810
|
+
${renderEventSummary(events, () => {
|
|
1811
|
+
chunk.ids.forEach((id) =>
|
|
1812
|
+
this.expandedEventChunks.add(id)
|
|
1813
|
+
);
|
|
1814
|
+
this.requestUpdate();
|
|
1815
|
+
})}
|
|
1816
|
+
</div>
|
|
1817
|
+
</div>`;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// collapsing runs keep rendering expanded while the
|
|
1821
|
+
// closing animation plays, then swap to the summary
|
|
1822
|
+
const collapsing = chunk.ids.some((id) =>
|
|
1823
|
+
this.collapsingEventChunks.has(id)
|
|
1824
|
+
);
|
|
1825
|
+
return html`<div
|
|
1826
|
+
class="row message is-event condensed-events ${collapsing
|
|
1827
|
+
? 'collapsing'
|
|
1828
|
+
: ''}"
|
|
1829
|
+
>
|
|
1830
|
+
<div class="reveal">
|
|
1831
|
+
<div class="reveal-inner">
|
|
1832
|
+
<div class="run">
|
|
1833
|
+
<div
|
|
1834
|
+
class="collapse-rail"
|
|
1835
|
+
title="Collapse"
|
|
1836
|
+
@click=${() => {
|
|
1837
|
+
if (collapsing) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
chunk.ids.forEach((id) =>
|
|
1841
|
+
this.collapsingEventChunks.add(id)
|
|
1842
|
+
);
|
|
1843
|
+
this.requestUpdate();
|
|
1844
|
+
window.setTimeout(() => {
|
|
1845
|
+
chunk.ids.forEach((id) => {
|
|
1846
|
+
this.collapsingEventChunks.delete(id);
|
|
1847
|
+
this.expandedEventChunks.delete(id);
|
|
1848
|
+
});
|
|
1849
|
+
this.requestUpdate();
|
|
1850
|
+
}, 180);
|
|
1851
|
+
}}
|
|
1852
|
+
>
|
|
1853
|
+
<temba-icon name="up" size="0.85"></temba-icon>
|
|
1854
|
+
<div class="rail-line"></div>
|
|
1855
|
+
</div>
|
|
1856
|
+
<div class="events">
|
|
1857
|
+
${chunk.ids.map((msgId) => {
|
|
1858
|
+
const msg = this.msgMap.get(msgId);
|
|
1859
|
+
return html`<div
|
|
1860
|
+
class="event"
|
|
1861
|
+
data-uuid=${msg.uuid || nothing}
|
|
1862
|
+
>
|
|
1863
|
+
${msg._rendered.html}
|
|
1864
|
+
</div>`;
|
|
1865
|
+
})}
|
|
1866
|
+
</div>
|
|
1867
|
+
</div>
|
|
1868
|
+
</div>
|
|
1869
|
+
</div>
|
|
1870
|
+
</div>`;
|
|
1871
|
+
}
|
|
1872
|
+
return chunk.ids.map((msgId, chunkIndex) => {
|
|
1873
|
+
const index = chunk.start + chunkIndex;
|
|
1874
|
+
const msg = this.msgMap.get(msgId);
|
|
1875
|
+
const msgEvent = msg as MsgEvent;
|
|
1876
|
+
const statusClass = (msg as any)._status
|
|
1877
|
+
? (msg as any)._status.status
|
|
1878
|
+
: '';
|
|
1879
|
+
const hasError =
|
|
1880
|
+
msgEvent.msg?.unsendable_reason ||
|
|
1881
|
+
(msgEvent._status?.reason &&
|
|
1882
|
+
(statusClass === 'failed' || statusClass === 'errored'));
|
|
1883
|
+
const unsendableClass = hasError ? 'error' : '';
|
|
1884
|
+
const deletedClass = msgEvent._deleted ? 'deleted' : '';
|
|
1885
|
+
const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
|
|
1886
|
+
const latestClass = index === msgIds.length - 1 ? 'latest' : '';
|
|
1887
|
+
const eventClass = msg._rendered ? 'is-event' : '';
|
|
1888
|
+
const noteClass =
|
|
1889
|
+
msg.type === 'ticket_note_added' ? 'note' : '';
|
|
1890
|
+
const typingClass =
|
|
1891
|
+
msg.type === 'typing_started' ? 'typing' : '';
|
|
1892
|
+
const matchClass =
|
|
1893
|
+
this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
|
|
1894
|
+
return html`<div
|
|
1895
|
+
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${typingClass} ${matchClass}"
|
|
1896
|
+
data-uuid=${msg.uuid || nothing}
|
|
1897
|
+
>
|
|
1898
|
+
${this.renderMessage(msg, index == 0 ? name : null)}
|
|
1899
|
+
</div>`;
|
|
1900
|
+
});
|
|
1557
1901
|
}
|
|
1558
1902
|
)}
|
|
1559
1903
|
</div>
|
|
@@ -1574,44 +1918,38 @@ export class Chat extends RapidElement {
|
|
|
1574
1918
|
${showReason
|
|
1575
1919
|
? html`<div class="group-reason">${reasonLabel}</div>`
|
|
1576
1920
|
: null}
|
|
1577
|
-
${timeForReason}
|
|
1578
1921
|
`;
|
|
1579
1922
|
|
|
1580
|
-
return
|
|
1923
|
+
return resultHtml;
|
|
1581
1924
|
}
|
|
1582
1925
|
|
|
1583
|
-
private renderNote(
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
isLast: boolean
|
|
1587
|
-
): TemplateResult {
|
|
1926
|
+
private renderNote(event: TicketEvent, name: string | null): TemplateResult {
|
|
1927
|
+
// notes carry their time the same way messages do — in the
|
|
1928
|
+
// hover popup — rather than printing it under the bubble
|
|
1588
1929
|
return html`<div class="bubble-wrap">
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
<div style="white-space: pre-wrap;">${event.note}</div>
|
|
1592
|
-
</div>
|
|
1593
|
-
${isLast
|
|
1594
|
-
? html`<div class="note-time">
|
|
1930
|
+
${this.showTimestamps
|
|
1931
|
+
? html`<div class="popup" style="white-space: nowrap;">
|
|
1595
1932
|
<temba-date
|
|
1596
|
-
value
|
|
1597
|
-
display="
|
|
1933
|
+
value="${event.created_on.toISOString()}"
|
|
1934
|
+
display="datetime"
|
|
1598
1935
|
></temba-date>
|
|
1936
|
+
<div class="arrow"></div>
|
|
1599
1937
|
</div>`
|
|
1600
1938
|
: null}
|
|
1939
|
+
<div class="bubble">
|
|
1940
|
+
${name ? html`<div class="name">${name}</div>` : null}
|
|
1941
|
+
<div style="white-space: pre-wrap;">${event.note}</div>
|
|
1942
|
+
</div>
|
|
1601
1943
|
</div>`;
|
|
1602
1944
|
}
|
|
1603
1945
|
|
|
1604
|
-
private renderMessage(
|
|
1605
|
-
event: ContactEvent,
|
|
1606
|
-
name = null,
|
|
1607
|
-
isLast = false
|
|
1608
|
-
): TemplateResult {
|
|
1946
|
+
private renderMessage(event: ContactEvent, name = null): TemplateResult {
|
|
1609
1947
|
if (event._rendered) {
|
|
1610
1948
|
return html`<div class="event">${event._rendered.html}</div>`;
|
|
1611
1949
|
}
|
|
1612
1950
|
|
|
1613
1951
|
if (event.type === 'ticket_note_added') {
|
|
1614
|
-
return this.renderNote(event as TicketEvent, name
|
|
1952
|
+
return this.renderNote(event as TicketEvent, name);
|
|
1615
1953
|
}
|
|
1616
1954
|
|
|
1617
1955
|
if (event.type === 'typing_started') {
|
|
@@ -1684,11 +2022,11 @@ export class Chat extends RapidElement {
|
|
|
1684
2022
|
: null}
|
|
1685
2023
|
<temba-date
|
|
1686
2024
|
value="${message.created_on.toISOString()}"
|
|
1687
|
-
display="
|
|
2025
|
+
display="datetime"
|
|
1688
2026
|
></temba-date>
|
|
1689
2027
|
${logsURL
|
|
1690
2028
|
? html`<a
|
|
1691
|
-
|
|
2029
|
+
class="log-link"
|
|
1692
2030
|
href="${logsURL}"
|
|
1693
2031
|
target="_blank"
|
|
1694
2032
|
rel="noopener noreferrer"
|
|
@@ -1696,7 +2034,7 @@ export class Chat extends RapidElement {
|
|
|
1696
2034
|
></a>`
|
|
1697
2035
|
: null}
|
|
1698
2036
|
|
|
1699
|
-
<div class="arrow"
|
|
2037
|
+
<div class="arrow"></div>
|
|
1700
2038
|
</div>`
|
|
1701
2039
|
: null}
|
|
1702
2040
|
${isDeleted
|
|
@@ -1737,6 +2075,8 @@ export class Chat extends RapidElement {
|
|
|
1737
2075
|
this.typingEvents.clear();
|
|
1738
2076
|
this.msgMap.clear();
|
|
1739
2077
|
this.metadataCache.clear();
|
|
2078
|
+
this.expandedEventChunks.clear();
|
|
2079
|
+
this.collapsingEventChunks.clear();
|
|
1740
2080
|
this.messageGroups = [];
|
|
1741
2081
|
this.hideBottomScroll = true;
|
|
1742
2082
|
this.hideTopScroll = true;
|
|
@@ -1759,36 +2099,46 @@ export class Chat extends RapidElement {
|
|
|
1759
2099
|
<div class="scroll" @scroll=${this.handleScroll}>
|
|
1760
2100
|
${this.messageGroups
|
|
1761
2101
|
? (() => {
|
|
1762
|
-
|
|
1763
|
-
//
|
|
1764
|
-
//
|
|
1765
|
-
|
|
2102
|
+
// bucket groups into calendar-day sections, oldest to
|
|
2103
|
+
// newest (high index to low index). Each section renders
|
|
2104
|
+
// with a sticky day marker that floats over its messages
|
|
2105
|
+
// while they scroll by.
|
|
2106
|
+
const sections: {
|
|
2107
|
+
key: string;
|
|
2108
|
+
label: string;
|
|
2109
|
+
groups: TemplateResult[];
|
|
2110
|
+
}[] = [];
|
|
1766
2111
|
for (let idx = this.messageGroups.length - 1; idx >= 0; idx--) {
|
|
1767
2112
|
const msgGroup = this.messageGroups[idx];
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
)
|
|
1773
|
-
|
|
1774
|
-
|
|
2113
|
+
const firstMsg = this.msgMap.get(msgGroup.messages[0]);
|
|
2114
|
+
const day = firstMsg?.created_on || new Date();
|
|
2115
|
+
const key = getDayKey(day);
|
|
2116
|
+
let section = sections[sections.length - 1];
|
|
2117
|
+
if (!section || section.key !== key) {
|
|
2118
|
+
section = { key, label: getDayLabel(day), groups: [] };
|
|
2119
|
+
sections.push(section);
|
|
2120
|
+
}
|
|
2121
|
+
// oldest first within the section — the section is a
|
|
2122
|
+
// normal top-down column, only the scroller reverses
|
|
2123
|
+
section.groups.push(this.renderMessageGroup(msgGroup));
|
|
1775
2124
|
}
|
|
1776
|
-
|
|
2125
|
+
// newest section first in DOM for the column-reverse scroller
|
|
2126
|
+
sections.reverse();
|
|
2127
|
+
return sections.map(
|
|
2128
|
+
(section) =>
|
|
2129
|
+
html`<div class="day-section">
|
|
2130
|
+
<div class="day-marker">
|
|
2131
|
+
<div class="day">${section.label}</div>
|
|
2132
|
+
</div>
|
|
2133
|
+
${section.groups}
|
|
2134
|
+
</div>`
|
|
2135
|
+
);
|
|
1777
2136
|
})()
|
|
1778
2137
|
: null}
|
|
1779
2138
|
|
|
1780
2139
|
<temba-loading
|
|
1781
2140
|
class="${!this.fetching ? 'hidden' : ''}"
|
|
1782
2141
|
></temba-loading>
|
|
1783
|
-
|
|
1784
|
-
${this.endOfHistory && this.oldestEventDate
|
|
1785
|
-
? html`<div class="time first">
|
|
1786
|
-
${this.oldestEventDate.toLocaleTimeString(
|
|
1787
|
-
undefined,
|
|
1788
|
-
VERBOSE_FORMAT
|
|
1789
|
-
)}
|
|
1790
|
-
</div>`
|
|
1791
|
-
: null}
|
|
1792
2142
|
</div>
|
|
1793
2143
|
${!this.hasFooter
|
|
1794
2144
|
? html`<div
|