@nyaruka/temba-components 0.167.0 → 0.168.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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
@@ -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
- const TIME_FORMAT = { hour: 'numeric', minute: '2-digit' } as any;
152
- const VERBOSE_FORMAT = {
153
- weekday: undefined,
154
- year: undefined,
155
- month: 'short',
156
- day: 'numeric',
157
- hour: 'numeric',
158
- minute: '2-digit'
159
- } as any;
160
- const VERBOSE_FORMAT_WITH_YEAR = {
161
- weekday: undefined,
162
- year: 'numeric',
163
- month: 'short',
164
- day: 'numeric',
165
- hour: 'numeric',
166
- minute: '2-digit'
167
- } as any;
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
- .time {
227
- text-align: center;
228
- font-size: 0.8em;
229
- color: #999;
230
- margin-bottom: 2em;
231
- margin-top: 1em;
232
- border-top: 1px solid #e9e9e9;
233
- padding: 1em;
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
- .time.first {
239
- border-top: none;
240
- margin-top: 0;
241
- border-bottom: 1px solid #e9e9e9;
242
- margin-bottom: 2em;
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
- .first .time {
246
- margin-top: 0;
247
- border-top: none;
248
- padding-top: 0;
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: var(--color-chat-in, #e5e5ea);
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(--chat-border-in, none);
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: var(--color-chat-out, #007aff);
339
- border: var(--chat-border-out, none);
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: #fffac3;
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
- padding: 0.5em 1em;
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
- justify-content: center;
782
- text-align: center;
783
- font-size: 1.3em;
784
- transform: translateY(0.7em) scale(1);
785
- color: #fff;
786
- bottom: 0;
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
- transition-delay: 1s;
792
- top: -35px;
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
- // check time first - if BATCH_TIME_WINDOW has passed since last time_elapsed reason
1219
- // compare the current message (msg1) against the last break point to detect
1220
- // when messages have drifted far enough to warrant a new time group
1221
- const timeToCheck = lastTimeElapsedDate || msg2.created_on;
1222
- if (
1223
- Math.abs(msg1.created_on.getTime() - timeToCheck.getTime()) >=
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, lastTimeElapsedDate);
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
- // determine if we should show a timestamp
1486
- // use the first message in the group (oldest) for the timestamp
1487
- const firstMsgId = msgIds[0];
1488
- const firstMsg = this.msgMap.get(firstMsgId);
1489
-
1490
- // check if we should show a timestamp based on the last shown timestamp
1491
- let showTimeForReason = false;
1492
- let newLastShownTimestamp = lastShownTimestamp;
1493
-
1494
- if (idx > 0) {
1495
- if (lastShownTimestamp) {
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
- showTimeForReason = group.reason === 'time_elapsed';
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
- msgIds,
1527
- (msgId) => msgId,
1528
- (msgId, index) => {
1529
- const msg = this.msgMap.get(msgId);
1530
- const msgEvent = msg as MsgEvent;
1531
- const statusClass = (msg as any)._status
1532
- ? (msg as any)._status.status
1533
- : '';
1534
- const hasError =
1535
- msgEvent.msg?.unsendable_reason ||
1536
- (msgEvent._status?.reason &&
1537
- (statusClass === 'failed' || statusClass === 'errored'));
1538
- const unsendableClass = hasError ? 'error' : '';
1539
- const deletedClass = msgEvent._deleted ? 'deleted' : '';
1540
- const emptyClass = isEmptyMsg(msgEvent) ? 'empty' : '';
1541
- const latestClass = index === msgIds.length - 1 ? 'latest' : '';
1542
- const eventClass = msg._rendered ? 'is-event' : '';
1543
- const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
1544
- const typingClass = msg.type === 'typing_started' ? 'typing' : '';
1545
- const matchClass =
1546
- this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
1547
- return html`<div
1548
- class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${typingClass} ${matchClass}"
1549
- data-uuid=${msg.uuid || nothing}
1550
- >
1551
- ${this.renderMessage(
1552
- msg,
1553
- index == 0 ? name : null,
1554
- index === msgIds.length - 1
1555
- )}
1556
- </div>`;
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 { html: resultHtml, timestamp: newLastShownTimestamp };
1923
+ return resultHtml;
1581
1924
  }
1582
1925
 
1583
- private renderNote(
1584
- event: TicketEvent,
1585
- name: string | null,
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
- <div class="bubble">
1590
- ${name ? html`<div class="name">${name}</div>` : null}
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=${event.created_on.toISOString()}
1597
- display="relative"
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, isLast);
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="relative"
2025
+ display="datetime"
1688
2026
  ></temba-date>
1689
2027
  ${logsURL
1690
2028
  ? html`<a
1691
- style="margin-left: 1em; color: var(--color-primary-dark);"
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">▼</div>
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
- let lastTimestamp: Date | null = null;
1763
- // process from oldest to newest (high index to low index)
1764
- // to establish logical time groupings going forward in time
1765
- const results = [];
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 result = this.renderMessageGroup(
1769
- msgGroup,
1770
- idx,
1771
- lastTimestamp
1772
- );
1773
- lastTimestamp = result.timestamp;
1774
- results.unshift(result.html); // add to front since we're going backwards
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
- return results;
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