@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +27 -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 +2842 -1715
  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 +24 -17
  42. package/src/list/ContentList.ts +640 -33
  43. package/src/list/MsgList.ts +7 -1
  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
@@ -8,7 +8,7 @@ import {
8
8
  TemplateResult
9
9
  } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
11
- import { msg } from '@lit/localize';
11
+ import { msg, str } from '@lit/localize';
12
12
  import {
13
13
  Contact,
14
14
  CustomEventType,
@@ -26,7 +26,11 @@ import {
26
26
  } from '../utils';
27
27
  import { ContactStoreElement } from './ContactStoreElement';
28
28
  import { Compose, ComposeValue } from '../form/Compose';
29
- import { ContactHistoryPage } from '../events';
29
+ import {
30
+ ContactFlowChangedEvent,
31
+ ContactHistoryPage,
32
+ ContactLastSeenChangedEvent
33
+ } from '../events';
30
34
  import {
31
35
  Chat,
32
36
  MessageType,
@@ -38,17 +42,25 @@ import { DEFAULT_AVATAR } from '../webchat/assets';
38
42
  import { UserSelect } from '../form/select/UserSelect';
39
43
  import { Select } from '../form/select/Select';
40
44
  import {
45
+ Events,
41
46
  renderEvent,
42
47
  renderTicketAction,
43
48
  renderTicketAssigneeChanged
44
49
  } from '../events/eventRenderers';
45
50
  import { publishToSocket } from './SocketService';
46
51
  import { subscribeToContactHistory, RealtimeSubscription } from './Realtime';
52
+ import { Icon } from '../Icons';
53
+ import { designTokens } from '../styles/designTokens';
54
+ import { DateTime } from 'luxon';
47
55
 
48
56
  // how often we re-publish typing_started while composing - just inside the
49
57
  // tightest platform sustain interval (Telegram's indicator lapses after 5s)
50
58
  const TYPING_PULSE_INTERVAL = 4000;
51
59
 
60
+ // how often we re-render idle conversations so the last seen duration
61
+ // doesn't go stale
62
+ const STATE_REFRESH_INTERVAL = 60000;
63
+
52
64
  // re-export for backwards compatibility
53
65
  export { renderTicketAction, renderTicketAssigneeChanged };
54
66
 
@@ -64,10 +76,12 @@ interface SearchResult {
64
76
  export class ContactChat extends ContactStoreElement {
65
77
  public static get styles() {
66
78
  return css`
79
+ ${designTokens}
80
+
67
81
  :host {
68
82
  flex-grow: 1;
69
83
  display: flex;
70
- flex-direction: row;
84
+ flex-direction: column;
71
85
  min-height: 0;
72
86
  margin-top: var(--gap);
73
87
  --compose-shadow: none;
@@ -101,7 +115,33 @@ export class ContactChat extends ContactStoreElement {
101
115
  --compose-border: 1px solid #e1e1e1;
102
116
  --compose-curvature: 6px;
103
117
  margin: 0.5em;
104
- background: #f9f9f9;
118
+ /* white keeps the input readable as a card against the tinted
119
+ chat box region behind it - rounded to match the compose
120
+ container so the corners don't bleed past its border */
121
+ background: #fff;
122
+ border-radius: var(--compose-curvature);
123
+ }
124
+
125
+ /* The chat box focuses with a solid outline and no exterior
126
+ glow - just this control, other widgets keep the design-system
127
+ halo. The focus vars must be set on temba-compose itself - its
128
+ shadow :host declares them via the design tokens, which beats
129
+ anything inherited from .compose. */
130
+ .compose temba-compose {
131
+ --color-focus: var(--focus, rgb(91, 156, 229));
132
+ --widget-box-shadow-focused: none;
133
+ }
134
+
135
+ /* While the contact is in a flow the compose outline carries the
136
+ flow hue: resting border matches the flow pill border, focus
137
+ goes solid flow green */
138
+ .in-flow .compose {
139
+ --compose-border: 1px solid
140
+ color-mix(in srgb, var(--flow, #16a34a) 25%, white);
141
+ }
142
+
143
+ .in-flow .compose temba-compose {
144
+ --color-focus: var(--flow, #16a34a);
105
145
  }
106
146
 
107
147
  .closed-footer {
@@ -134,12 +174,6 @@ export class ContactChat extends ContactStoreElement {
134
174
  --color-widget-bg-focused: transparent;
135
175
  }
136
176
 
137
- .border {
138
- }
139
-
140
- temba-compose {
141
- }
142
-
143
177
  .error-gutter {
144
178
  display: flex;
145
179
  padding: 0.5em 1em;
@@ -154,11 +188,19 @@ export class ContactChat extends ContactStoreElement {
154
188
  align-self: center;
155
189
  }
156
190
 
191
+ /* The history draws no bottom border - each strip below it
192
+ (ticket bar, status row, chat box) owns its top border
193
+ instead, so the separator always belongs to the strip it
194
+ introduces. The status row's goes flow-green while the
195
+ contact is in a flow, which places the green line below the
196
+ ticket bar when there is one and directly below the history
197
+ when there isn't. */
157
198
  temba-chat {
158
- border-bottom: 1px solid #ddd;
159
199
  background: linear-gradient(0deg, #fff, #fff);
160
- --chat-border-in: 1px solid #eee;
161
200
  --color-chat-out: var(--color-message);
201
+ /* nothing floats over the bottom of the history anymore - the
202
+ status area below holds that content, so no reserved space */
203
+ --chat-bottom-padding: 1em;
162
204
  transition: opacity 0.15s ease;
163
205
  }
164
206
 
@@ -170,18 +212,28 @@ export class ContactChat extends ContactStoreElement {
170
212
  min-height: 0;
171
213
  }
172
214
 
215
+ /* The search bar rides above the chat card — outside its border —
216
+ so sliding it in shrinks the card to make room instead of
217
+ overlaying the history. It borrows the list pages' searchbar
218
+ layout (bare single-line input + trailing icon controls) but
219
+ wears the card chrome (surface + border + shadow) instead of the
220
+ sunken strip — the page background here is grey, so a sunken bar
221
+ would blend right into it. The gap below it is the layout's
222
+ spacing unit, matching the gap between the chat and the cards. */
173
223
  @keyframes search-slide-in {
174
224
  from {
175
225
  max-height: 0;
176
226
  opacity: 0;
177
227
  padding-top: 0;
178
228
  padding-bottom: 0;
229
+ margin-bottom: 0;
179
230
  }
180
231
  to {
181
232
  max-height: 3em;
182
233
  opacity: 1;
183
- padding-top: 0.5em;
184
- padding-bottom: 0.5em;
234
+ padding-top: 4px;
235
+ padding-bottom: 4px;
236
+ margin-bottom: var(--layout-spacing, 8px);
185
237
  }
186
238
  }
187
239
 
@@ -189,102 +241,129 @@ export class ContactChat extends ContactStoreElement {
189
241
  from {
190
242
  max-height: 3em;
191
243
  opacity: 1;
192
- padding-top: 0.5em;
193
- padding-bottom: 0.5em;
244
+ padding-top: 4px;
245
+ padding-bottom: 4px;
246
+ margin-bottom: var(--layout-spacing, 8px);
194
247
  }
195
248
  to {
196
249
  max-height: 0;
197
250
  opacity: 0;
198
251
  padding-top: 0;
199
252
  padding-bottom: 0;
253
+ margin-bottom: 0;
200
254
  }
201
255
  }
202
256
 
203
- .search-overlay {
257
+ .searchbar {
258
+ flex: 0 0 auto;
204
259
  display: flex;
205
260
  align-items: center;
206
- gap: 0.25em;
207
- padding: 1em 1em;
208
- border-bottom: 1px solid #ddd;
261
+ gap: 6px;
262
+ padding: 4px 12px;
263
+ margin-bottom: var(--layout-spacing, 8px);
264
+ background: var(--surface);
265
+ border: 1px solid var(--border-strong);
266
+ border-radius: var(--r-sm);
267
+ box-shadow: var(--shadow-2);
268
+ color: var(--text-3);
269
+ font-family: var(--font);
270
+ font-size: 13.5px;
209
271
  overflow: hidden;
210
272
  animation: search-slide-in 0.15s ease-out;
211
273
  }
212
274
 
213
- .search-overlay.closing {
275
+ .searchbar.closing {
214
276
  animation: search-slide-out 0.15s ease-in forwards;
215
277
  }
216
278
 
217
- .search-input-wrapper {
218
- flex-grow: 1;
279
+ .searchbar input {
280
+ flex: 1 1 auto;
281
+ border: 0;
282
+ background: transparent;
283
+ outline: 0;
284
+ font: inherit;
285
+ color: var(--text-1);
219
286
  min-width: 0;
220
- position: relative;
221
- margin-right: 0.5em;
287
+ /* match the stepper buttons' height so the bar doesn't grow
288
+ when results (and their page buttons) appear */
289
+ height: 22px;
222
290
  }
223
291
 
224
- .search-input {
225
- --temba-textinput-padding: 6px 28px 6px 8px;
292
+ .searchbar input::placeholder {
293
+ color: var(--text-3);
226
294
  }
227
295
 
228
- .search-go {
229
- position: absolute;
230
- right: 4px;
231
- top: 50%;
232
- transform: translateY(-50%);
233
- display: flex;
234
- align-items: center;
235
- justify-content: center;
236
- background: none;
237
- border: none;
238
- cursor: pointer;
239
- padding: 0.15em;
240
- border-radius: 3px;
241
- color: #999;
242
- z-index: 1;
296
+ /* "↵ to search" hint — fades in alongside the run-search icon
297
+ only while there's a pending draft to apply. */
298
+ .searchbar .search-hint {
299
+ flex: 0 0 auto;
300
+ font-size: 0.75em;
301
+ color: var(--text-3);
302
+ white-space: nowrap;
303
+ user-select: none;
243
304
  }
244
305
 
245
- .search-go:hover {
246
- color: #333;
306
+ .searchbar .search-hint .enter-key {
307
+ position: relative;
308
+ top: 2px;
247
309
  }
248
310
 
249
- .search-go:disabled {
250
- opacity: 0.3;
251
- cursor: default;
311
+ /* Standard clickable icons — bare glyph at rest, a circular wash
312
+ on hover. The default wash is near-white and disappears against
313
+ the white bar, so each carries a more saturated one; run search
314
+ only renders when a draft is pending, so it's always the
315
+ primary action and its wash warms to accent. */
316
+ .searchbar .search-go,
317
+ .searchbar .search-cancel {
318
+ flex: 0 0 auto;
319
+ margin-left: 5px;
320
+ --icon-color: var(--text-3);
321
+ --icon-color-circle-hover: rgba(15, 22, 36, 0.1);
252
322
  }
253
323
 
254
- .search-counter {
255
- font-size: 11px;
256
- color: #666;
257
- white-space: nowrap;
258
- min-width: 3em;
259
- text-align: center;
324
+ .searchbar .search-go {
325
+ --icon-color-circle-hover: var(--accent-200);
260
326
  }
261
327
 
262
- .search-btn {
328
+ /* Match stepper — the list pager's chevron-only page buttons
329
+ bracketing a quiet "n of N" count, stepping through matches
330
+ instead of pages. */
331
+ .match-pager {
332
+ flex: 0 0 auto;
263
333
  display: flex;
264
334
  align-items: center;
265
- justify-content: center;
266
- background: none;
267
- border: none;
268
- cursor: pointer;
269
- padding: 0.25em;
270
- border-radius: 4px;
271
- color: #666;
272
- font-size: 11px;
335
+ gap: 2px;
273
336
  }
274
337
 
275
- .search-btn:hover,
276
- .search-btn.enter-target:not(:disabled) {
277
- background: rgba(0, 0, 0, 0.08);
278
- color: #333;
338
+ .pager-status {
339
+ padding: 0 4px;
340
+ color: var(--text-3);
341
+ font-size: 12.5px;
342
+ white-space: nowrap;
343
+ }
344
+
345
+ .page-btn {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ justify-content: center;
349
+ width: 22px;
350
+ height: 22px;
351
+ border-radius: var(--r-sm);
352
+ color: var(--text-3);
353
+ cursor: pointer;
354
+ user-select: none;
279
355
  }
280
356
 
281
- .search-btn:disabled {
282
- opacity: 0.3;
283
- cursor: default;
357
+ /* enter-target lights the button Enter would fire — the older-
358
+ match step while the input holds the already-searched query. */
359
+ .page-btn:hover,
360
+ .page-btn.enter-target {
361
+ background: var(--sunken);
362
+ color: var(--text-1);
284
363
  }
285
364
 
286
- .search-btn:disabled:hover {
287
- background: none;
365
+ .page-btn temba-icon {
366
+ --icon-color: currentColor;
288
367
  }
289
368
 
290
369
  .search-no-results {
@@ -292,128 +371,179 @@ export class ContactChat extends ContactStoreElement {
292
371
  top: 50%;
293
372
  left: 50%;
294
373
  transform: translate(-50%, -50%);
295
- color: #999;
296
- font-size: 14px;
374
+ color: var(--text-3);
375
+ font-family: var(--font);
376
+ font-size: 13.5px;
297
377
  text-align: center;
298
378
  pointer-events: none;
299
379
  z-index: 5;
300
380
  }
301
381
 
382
+ /* The search trigger floats over the history's top-right corner,
383
+ styled as the list pages' Search action chip — bordered icon +
384
+ label — but on an opaque surface (with a soft shadow) since it
385
+ sits over chat content rather than a header. */
302
386
  .search-toggle {
303
387
  position: absolute;
304
- top: 0.5em;
388
+ top: 0.75em;
305
389
  right: 1.5em;
306
390
  z-index: 5;
307
- display: flex;
391
+ display: inline-flex;
308
392
  align-items: center;
309
- justify-content: center;
310
- background: rgba(255, 255, 255, 0.9);
311
- border: 1px solid #ddd;
312
- border-radius: 50%;
313
- width: 2em;
314
- height: 2em;
393
+ gap: 6px;
394
+ height: 26px;
395
+ box-sizing: border-box;
396
+ padding: 0 10px;
397
+ border: 1px solid var(--border-strong);
398
+ border-radius: var(--r-sm);
399
+ background: var(--surface);
400
+ color: var(--text-2);
401
+ font-family: var(--font);
402
+ font-size: 13px;
315
403
  cursor: pointer;
316
- color: #888;
317
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
404
+ user-select: none;
405
+ box-shadow: var(--shadow-1);
318
406
  }
319
407
 
320
408
  .search-toggle:hover {
321
- background: #fff;
322
- color: #333;
323
- border-color: #ccc;
324
- }
325
-
326
- /* "Currently in [flow]" treatment.
327
- Lives in the chat footer to advertise the active run with an
328
- optional Interrupt action (the chip's X). Sized to its
329
- contents only (inline-flex) so the chat scrollbar to the
330
- right remains clickable, and pointer-events:none on the
331
- wrapping footer means the rest of the row doesn't intercept
332
- scrollbar drags either. Translucent white bg + backdrop
333
- blur keeps the chat history legible through the chip. */
334
- .in-flow {
335
- display: inline-flex;
336
- align-items: center;
337
- gap: 8px;
338
- padding: 0.4em 0.75em;
339
- margin: 0.5em;
340
- border-radius: 999px;
341
- background: rgba(255, 255, 255, 0.75);
342
- backdrop-filter: blur(8px);
343
- -webkit-backdrop-filter: blur(8px);
344
- box-shadow: var(--shadow-1);
409
+ background: var(--sunken);
410
+ color: var(--text-1);
345
411
  }
346
412
 
347
- .flow-footer {
348
- text-align: center;
349
- pointer-events: none;
350
- /* The chat history has a scrollbar on the right edge; the
351
- footer overlay spans the full container width, so centering
352
- inside it lands the chip slightly right-of-center relative
353
- to the visible message area. Reserve the scrollbar width on
354
- the right so the chip is centered to what the user sees. */
355
- padding-right: 15px;
413
+ .search-toggle temba-icon {
414
+ --icon-color: currentColor;
356
415
  }
357
416
 
358
- .flow-footer .in-flow {
359
- pointer-events: auto;
417
+ /* The single status area above the chat box. Holds the ticket
418
+ controls (assignee / topic / close) and the contact status
419
+ (last seen, current flow) as stacked rows sharing one tint so
420
+ everything about the conversation's state lives in one place,
421
+ between the history and the compose. */
422
+ .status-area {
423
+ background: rgba(0, 0, 0, 0.02);
360
424
  }
361
425
 
362
- .in-flow .flow-name {
363
- display: inline-flex;
426
+ /* the compose drops its top margin so the status area hugs the
427
+ chat box instead of floating above it */
428
+ .status-area + .chat-box .compose {
429
+ margin-top: 0;
430
+ }
431
+
432
+ /* Insets match .ticket-controls (px, not em - the rows have
433
+ different font sizes and em padding would drift the edges
434
+ apart) so the last seen lines up with the assignee widget box
435
+ and the Interrupt button sits flush with the Close button. */
436
+ .contact-status {
437
+ display: flex;
364
438
  align-items: center;
365
- gap: 6px;
366
- /* Match the chat history event text same hue + size — so
367
- the "Currently in" line reads as one of the events rather
368
- than its own UI chrome. */
369
- font-size: 13.5px;
439
+ justify-content: space-between;
440
+ /* px so the guaranteed breathing room between last seen and
441
+ the flow doesn't scale down with the row's small font - the
442
+ flow name ellipsizes before this gap ever compresses */
443
+ gap: 24px;
444
+ padding: 5px 8px;
445
+ font-size: 11.5px;
370
446
  color: #8e8e93;
447
+ border-top: 1px solid #ddd;
371
448
  }
372
449
 
373
- .in-flow .interrupt {
374
- text-align: center;
375
- align-self: stretch;
450
+ /* While the contact is in a flow the row takes the flow pill's
451
+ wash and foreground (see pillVariants .pill-flow) so it reads
452
+ as flow-tinted - the ticket controls above keep the plain
453
+ status-area grey. Fallbacks match the --flow design token for
454
+ pages without tokens. */
455
+ .in-flow .contact-status {
456
+ background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
457
+ color: var(--flow, #16a34a);
458
+ --icon-color: var(--flow, #16a34a);
459
+ border-top-color: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
460
+ }
461
+
462
+ /* compact the interrupt button to the row's caption scale */
463
+ .contact-status temba-button {
464
+ --button-small-height: 20px;
465
+ --button-small-x-pad: 6px;
466
+ --button-small-font-size: 11px;
467
+ }
468
+
469
+ .contact-status .last-seen {
470
+ white-space: nowrap;
471
+ /* last seen always reads in full - the flow side gives way */
472
+ flex-shrink: 0;
473
+ }
474
+
475
+ .contact-status .current-flow {
376
476
  display: flex;
377
477
  align-items: center;
378
- cursor: pointer;
379
- justify-content: center;
380
- padding: 0.5em 1em;
381
- font-weight: bold;
478
+ gap: 0.5em;
479
+ /* the flow name can be long - let it ellipsize instead of
480
+ crowding out the last seen side */
481
+ min-width: 0;
482
+ margin-left: auto;
483
+ }
484
+
485
+ .contact-status .current-flow .flow-link {
486
+ display: flex;
487
+ align-items: center;
488
+ gap: 0.35em;
489
+ color: inherit;
490
+ font-weight: 500;
491
+ text-decoration: none;
492
+ min-width: 0;
493
+ }
494
+
495
+ .contact-status .current-flow .flow-link .flow-name {
496
+ overflow: hidden;
497
+ text-overflow: ellipsis;
498
+ white-space: nowrap;
499
+ /* shrinks with the bar but keeps a couple of characters plus
500
+ the ellipsis so the name never collapses entirely */
501
+ min-width: 2em;
382
502
  }
383
503
 
384
- .in-flow .interrupt:hover {
385
- background: rgba(var(--error-rgb), 0.92);
504
+ .contact-status .current-flow .flow-link:hover .flow-name {
505
+ text-decoration: underline;
506
+ }
507
+
508
+ .contact-status .current-flow temba-button {
509
+ flex-shrink: 0;
510
+ margin-left: 0.25em;
386
511
  }
387
512
 
388
- .in-flow temba-icon,
389
- .in-ticket temba-icon {
390
- margin-right: 0.5em;
513
+ /* The compose region shares the status area tint so the panel is
514
+ framed by the same treatment top and bottom. */
515
+ .chat-box {
516
+ background: rgba(0, 0, 0, 0.02);
517
+ border-top: 1px solid #ddd;
391
518
  }
392
519
 
393
- .in-ticket-wrapper {
520
+ /* the status row and chat box share one tinted region - no line
521
+ between them */
522
+ .status-area + .chat-box {
523
+ border-top: none;
394
524
  }
395
525
 
396
- .in-ticket {
397
- box-shadow: none;
398
- padding: 0.5em 0.5em;
526
+ .in-flow .chat-box {
527
+ background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
528
+ }
529
+
530
+ .ticket-controls {
531
+ padding: 8px;
399
532
  text-align: center;
400
533
  align-items: center;
401
- border-bottom: 1px solid #ddd;
402
534
  display: flex;
403
- box-shadow: none;
404
- margin: 0em;
405
- background: rgba(0, 0, 0, 0.03);
535
+ border-top: 1px solid #ddd;
406
536
  }
407
537
 
408
538
  /* Keep the assignment + topic controls the same height as the
409
539
  Close button so the row reads as one strip. Shrink the user
410
540
  avatars (--temba-scale) so they fit in the smaller box. */
411
- .in-ticket temba-user-select,
412
- .in-ticket temba-select {
541
+ .ticket-controls temba-user-select,
542
+ .ticket-controls temba-select {
413
543
  --temba-select-min-height: 28px;
414
544
  }
415
545
 
416
- .in-ticket temba-user-select {
546
+ .ticket-controls temba-user-select {
417
547
  --temba-scale: 0.75;
418
548
  }
419
549
 
@@ -554,6 +684,10 @@ export class ContactChat extends ContactStoreElement {
554
684
  private typingPulse: number = null;
555
685
  private typingDisabled = false;
556
686
 
687
+ // periodic re-render keeping the last seen duration fresh while the
688
+ // conversation is idle
689
+ private stateRefresh: number = null;
690
+
557
691
  constructor() {
558
692
  super();
559
693
  }
@@ -584,12 +718,18 @@ export class ContactChat extends ContactStoreElement {
584
718
  super.connectedCallback();
585
719
  this.chat = this.shadowRoot.querySelector('temba-chat');
586
720
  this.updateSubscriptions();
721
+ this.stateRefresh = window.setInterval(
722
+ () => this.requestUpdate(),
723
+ STATE_REFRESH_INTERVAL
724
+ );
587
725
  }
588
726
 
589
727
  public disconnectedCallback() {
590
728
  super.disconnectedCallback();
591
729
  this.stopTyping();
592
730
  this.unsubscribeAll();
731
+ window.clearInterval(this.stateRefresh);
732
+ this.stateRefresh = null;
593
733
  }
594
734
 
595
735
  public updated(changedProperties: Map<string, any>) {
@@ -674,9 +814,23 @@ export class ContactChat extends ContactStoreElement {
674
814
  }
675
815
 
676
816
  private handleSocketEvent(event: any) {
817
+ if (!this.currentContact) {
818
+ return;
819
+ }
820
+
821
+ // ephemeral contact state updates - they don't touch the history view
822
+ // so they apply even while searching
823
+ if (
824
+ event.type === Events.CONTACT_LAST_SEEN_CHANGED ||
825
+ event.type === Events.CONTACT_FLOW_CHANGED
826
+ ) {
827
+ this.handleContactStateEvent(event);
828
+ return;
829
+ }
830
+
677
831
  // while searching we're viewing an arbitrary point in history, new
678
832
  // events will be picked up by the refetch when search closes
679
- if (!this.chat || this.searchMode || !this.currentContact) {
833
+ if (!this.chat || this.searchMode) {
680
834
  return;
681
835
  }
682
836
 
@@ -692,6 +846,51 @@ export class ContactChat extends ContactStoreElement {
692
846
  }
693
847
  }
694
848
 
849
+ /**
850
+ * Ephemeral events that update contact state rather than record history.
851
+ * They are never persisted, so this is the only place they can be applied.
852
+ * The current contact is the store's cached copy of the contact, so
853
+ * updating it in place also keeps the cache fresh for later readers.
854
+ */
855
+ private handleContactStateEvent(
856
+ event: ContactFlowChangedEvent | ContactLastSeenChangedEvent
857
+ ) {
858
+ const contact = this.currentContact;
859
+ if (event.type === Events.CONTACT_LAST_SEEN_CHANGED) {
860
+ const lastSeenOn = (event as ContactLastSeenChangedEvent).last_seen_on;
861
+ // last seen only ever moves forward - ignore out of order deliveries
862
+ if (
863
+ !contact.last_seen_on ||
864
+ new Date(lastSeenOn) > new Date(contact.last_seen_on)
865
+ ) {
866
+ contact.last_seen_on = lastSeenOn;
867
+ }
868
+ } else {
869
+ contact.flow = (event as ContactFlowChangedEvent).flow || null;
870
+ }
871
+ this.requestUpdate();
872
+ }
873
+
874
+ private getLastSeen(): { duration: string; title: string } | null {
875
+ const lastSeenOn = this.currentContact?.last_seen_on;
876
+ if (!lastSeenOn || !this.store) {
877
+ return null;
878
+ }
879
+
880
+ // recent activity speaks for itself in the chat - only surface last
881
+ // seen once the contact has been quiet for at least an hour
882
+ const lastSeen = DateTime.fromISO(lastSeenOn);
883
+ const minutes = DateTime.now().diff(lastSeen, 'minutes').minutes;
884
+ if (minutes < 60) {
885
+ return null;
886
+ }
887
+
888
+ return {
889
+ duration: this.store.getShortDuration(lastSeen),
890
+ title: lastSeen.toLocaleString(DateTime.DATETIME_SHORT)
891
+ };
892
+ }
893
+
695
894
  private handleTypingEvent(event: TypingEvent) {
696
895
  // our own publications are echoed back to us - ignore them
697
896
  if (event._user && this.userUuid && event._user.uuid === this.userUuid) {
@@ -841,7 +1040,29 @@ export class ContactChat extends ContactStoreElement {
841
1040
  }
842
1041
  }
843
1042
 
1043
+ /** Drops any search positioning — highlights, block flags, paging
1044
+ * anchors — and reloads the history at its normal, most-recent view. */
1045
+ private restoreUnsearchedView() {
1046
+ const chat = this.chat;
1047
+ if (!chat) {
1048
+ return;
1049
+ }
1050
+
1051
+ chat.searchHighlight = null;
1052
+ chat.highlightMessageUuid = null;
1053
+ chat.reset();
1054
+ this.blockFetching = false;
1055
+ this.blockFetchingNewer = false;
1056
+ this.fetchingNewer = false;
1057
+ this.beforeUUID = null;
1058
+ this.afterUUID = null;
1059
+ this.fetchPreviousMessages();
1060
+ }
1061
+
844
1062
  private handleSearchClose() {
1063
+ // supersede any in-flight match navigation right away — its chain
1064
+ // must not re-assert highlights over the restored view below
1065
+ this.searchGeneration++;
845
1066
  this.searchClosing = true;
846
1067
  window.setTimeout(() => {
847
1068
  this.searchClosing = false;
@@ -852,32 +1073,44 @@ export class ContactChat extends ContactStoreElement {
852
1073
  this.searchLoading = false;
853
1074
  this.searchNoResults = false;
854
1075
  this.lastSearchedQuery = '';
855
- const chat = this.chat;
856
- if (!chat) {
857
- return;
858
- }
859
-
860
- chat.searchHighlight = null;
861
- chat.highlightMessageUuid = null;
862
- // reload the current view
863
- chat.reset();
864
- this.blockFetching = false;
865
- this.blockFetchingNewer = false;
866
- this.fetchingNewer = false;
867
- this.beforeUUID = null;
868
- this.afterUUID = null;
869
- this.fetchPreviousMessages();
1076
+ this.restoreUnsearchedView();
870
1077
  }, 150);
871
1078
  }
872
1079
 
873
1080
  private handleSearchInput(e: Event) {
874
1081
  const input = e.target as HTMLInputElement;
875
1082
  this.searchQuery = input.value;
1083
+
1084
+ // any edit away from the searched query invalidates its results —
1085
+ // drop them and put the history back at its unsearched view rather
1086
+ // than staying parked at a stale match; backspacing to nothing is
1087
+ // just an empty search. Clearing lastSearchedQuery also means
1088
+ // retyping the same query and hitting Enter re-runs the search.
1089
+ if (this.searchQuery.trim() !== this.lastSearchedQuery) {
1090
+ // only reload the view when a search had actually run — while
1091
+ // typing a fresh query the history is already showing it
1092
+ const hadSearch = this.lastSearchedQuery !== '';
1093
+ this.searchGeneration++;
1094
+ this.searchResults = [];
1095
+ this.searchIndex = -1;
1096
+ this.searchNoResults = false;
1097
+ this.lastSearchedQuery = '';
1098
+ if (hadSearch) {
1099
+ this.restoreUnsearchedView();
1100
+ }
1101
+ }
876
1102
  }
877
1103
 
878
1104
  // tracks the query that produced the current searchResults
879
1105
  private lastSearchedQuery = '';
880
1106
 
1107
+ // bumped whenever the current search is superseded (new search, query
1108
+ // edit, close) — navigateToResult's async fade/load chain captures the
1109
+ // value at entry and bails at each step once it goes stale, so a chain
1110
+ // already in flight can't re-assert highlights or scroll position over
1111
+ // a view the user has since moved on from
1112
+ private searchGeneration = 0;
1113
+
881
1114
  private handleSearchKeydown(e: KeyboardEvent) {
882
1115
  if (e.key === 'Enter') {
883
1116
  e.preventDefault();
@@ -901,6 +1134,8 @@ export class ContactChat extends ContactStoreElement {
901
1134
  return;
902
1135
  }
903
1136
 
1137
+ // a fresh search supersedes any navigation still in flight
1138
+ this.searchGeneration++;
904
1139
  this.searchLoading = true;
905
1140
  this.searchResults = [];
906
1141
  this.searchIndex = -1;
@@ -916,7 +1151,23 @@ export class ContactChat extends ContactStoreElement {
916
1151
  getUrl(url)
917
1152
  .then((response: WebResponse) => {
918
1153
  this.searchLoading = false;
919
- this.searchResults = response.json.results || [];
1154
+ // ignore a response the user has already moved past — editing the
1155
+ // query (handleSearchInput clears lastSearchedQuery) or closing search
1156
+ // (handleSearchClose flips searchMode off) can land before a slow
1157
+ // response; applying it here would repopulate results for and scroll to
1158
+ // a superseded query.
1159
+ if (this.lastSearchedQuery !== query || !this.searchMode) {
1160
+ return;
1161
+ }
1162
+ // the stepper walks newest → oldest ("older match" steps forward
1163
+ // through the list), so order the results newest-first here rather
1164
+ // than depending on the endpoint's ordering (uuid v7 sorts
1165
+ // chronologically, matching the uuid comparisons used elsewhere)
1166
+ this.searchResults = (
1167
+ (response.json.results || []) as SearchResult[]
1168
+ ).sort((a, b) =>
1169
+ b.uuid.toLowerCase().localeCompare(a.uuid.toLowerCase())
1170
+ );
920
1171
  if (this.searchResults.length > 0) {
921
1172
  this.searchNoResults = false;
922
1173
  this.searchIndex = 0;
@@ -928,6 +1179,9 @@ export class ContactChat extends ContactStoreElement {
928
1179
  })
929
1180
  .catch(() => {
930
1181
  this.searchLoading = false;
1182
+ if (this.lastSearchedQuery !== query || !this.searchMode) {
1183
+ return;
1184
+ }
931
1185
  this.searchResults = [];
932
1186
  this.searchIndex = -1;
933
1187
  this.searchNoResults = false;
@@ -953,7 +1207,15 @@ export class ContactChat extends ContactStoreElement {
953
1207
  return;
954
1208
  }
955
1209
 
956
- this.chat.searchHighlight = this.searchQuery.trim();
1210
+ // the chain below spans fade timers and fetches — capture the current
1211
+ // generation so each step can bail if the search is superseded
1212
+ // (closed, edited, or re-run) while it's still in flight
1213
+ const generation = this.searchGeneration;
1214
+ const stale = () => generation !== this.searchGeneration;
1215
+
1216
+ // highlight the query that produced these results, not the current input
1217
+ // text — a draft edit mid-navigation must not mislabel the highlights.
1218
+ this.chat.searchHighlight = this.lastSearchedQuery;
957
1219
 
958
1220
  // start fetches immediately (in parallel with fade-out)
959
1221
  const beforePromise = fetchContactHistory(
@@ -973,6 +1235,13 @@ export class ContactChat extends ContactStoreElement {
973
1235
  this.chat.style.opacity = '0';
974
1236
 
975
1237
  window.setTimeout(() => {
1238
+ // superseded while fading out — leave the view to whatever
1239
+ // superseded us and just undo our fade
1240
+ if (stale()) {
1241
+ this.chat.style.opacity = '1';
1242
+ return;
1243
+ }
1244
+
976
1245
  // fully hidden (including scrollbar) during content swap
977
1246
  this.chat.style.visibility = 'hidden';
978
1247
  this.chat.reset();
@@ -990,6 +1259,12 @@ export class ContactChat extends ContactStoreElement {
990
1259
  let navigationCompleted = false;
991
1260
  Promise.all([beforePromise, afterPromise])
992
1261
  .then(([beforePage, afterPage]) => {
1262
+ // superseded while the pages were loading — the finally below
1263
+ // restores visibility since navigationCompleted is still false
1264
+ if (stale()) {
1265
+ return;
1266
+ }
1267
+
993
1268
  const afterMessages = this.createMessages(afterPage);
994
1269
  afterMessages.reverse();
995
1270
 
@@ -1033,6 +1308,16 @@ export class ContactChat extends ContactStoreElement {
1033
1308
  window.setTimeout(() => {
1034
1309
  if (!this.chat) return;
1035
1310
 
1311
+ // superseded after the messages rendered — don't re-assert
1312
+ // the highlight or scroll over the restored view, but do
1313
+ // undo our hide (navigationCompleted is already true, so
1314
+ // the finally won't)
1315
+ if (stale()) {
1316
+ this.chat.style.visibility = 'visible';
1317
+ this.chat.style.opacity = '1';
1318
+ return;
1319
+ }
1320
+
1036
1321
  // position internal scroll only — never use scrollIntoView
1037
1322
  // which can move ancestor containers
1038
1323
  this.chat.highlightMessageUuid = result.uuid;
@@ -1074,6 +1359,10 @@ export class ContactChat extends ContactStoreElement {
1074
1359
  });
1075
1360
  })
1076
1361
  .catch(() => {
1362
+ // superseded — whatever superseded us already owns the reload
1363
+ if (stale()) {
1364
+ return;
1365
+ }
1077
1366
  this.blockFetching = false;
1078
1367
  this.blockFetchingNewer = false;
1079
1368
  this.fetchingNewer = false;
@@ -1190,15 +1479,18 @@ export class ContactChat extends ContactStoreElement {
1190
1479
  /**
1191
1480
  * Keeps the store's avatar cache fresh from server-hydrated user refs and
1192
1481
  * fills in refs that arrive without one - events published over sockets
1193
- * carry only a user's uuid and name.
1482
+ * carry only a user's uuid and name. Ticket assignment events carry a
1483
+ * second user ref (the assignee) whose avatar feeds the event's hover
1484
+ * tooltip.
1194
1485
  */
1195
1486
  private resolveUserAvatar(event: any) {
1196
- const user = event._user;
1197
- if (user && user.uuid && this.store) {
1198
- if (user.avatar) {
1199
- this.store.setUserAvatar(user.uuid, user.avatar);
1200
- } else {
1201
- user.avatar = this.store.getUserAvatar(user.uuid);
1487
+ for (const user of [event._user, event.assignee]) {
1488
+ if (user && user.uuid && this.store) {
1489
+ if (user.avatar) {
1490
+ this.store.setUserAvatar(user.uuid, user.avatar);
1491
+ } else {
1492
+ user.avatar = this.store.getUserAvatar(user.uuid);
1493
+ }
1202
1494
  }
1203
1495
  }
1204
1496
  }
@@ -1442,7 +1734,7 @@ export class ContactChat extends ContactStoreElement {
1442
1734
  }
1443
1735
 
1444
1736
  private getCompose(): TemplateResult {
1445
- return html`<div class="border"></div>
1737
+ return html`<div class="chat-box">
1446
1738
  <div class="compose">
1447
1739
  <temba-compose
1448
1740
  attachments
@@ -1463,7 +1755,8 @@ export class ContactChat extends ContactStoreElement {
1463
1755
  ></temba-button>
1464
1756
  </div>`
1465
1757
  : null}
1466
- </div>`;
1758
+ </div>
1759
+ </div>`;
1467
1760
  }
1468
1761
 
1469
1762
  private handleAssignmentChanged(evt: CustomEvent) {
@@ -1565,177 +1858,203 @@ export class ContactChat extends ContactStoreElement {
1565
1858
  });
1566
1859
  }
1567
1860
 
1861
+ /** The search bar rendered above the chat card while searching —
1862
+ * mirrors the list pages' searchbar: a bare input on a sunken strip
1863
+ * with an ↵-to-search hint + run icon while a draft is pending, and
1864
+ * a pager-style match stepper once results are in. */
1865
+ private renderSearchBar(): TemplateResult {
1866
+ const trimmed = this.searchQuery.trim();
1867
+ const pendingDraft = !!trimmed && trimmed !== this.lastSearchedQuery;
1868
+ return html`<div class="searchbar ${this.searchClosing ? 'closing' : ''}">
1869
+ <input
1870
+ class="search-input"
1871
+ type="text"
1872
+ placeholder="Search messages"
1873
+ aria-label="Search messages"
1874
+ .value=${this.searchQuery}
1875
+ @input=${this.handleSearchInput}
1876
+ @keydown=${this.handleSearchKeydown}
1877
+ @focus=${() => (this.searchFocused = true)}
1878
+ @blur=${() => (this.searchFocused = false)}
1879
+ />
1880
+ ${this.searchLoading
1881
+ ? html`<temba-loading size="8"></temba-loading>`
1882
+ : pendingDraft
1883
+ ? html`<span class="search-hint">
1884
+ <span class="enter-key">&#x21B5;</span> to search
1885
+ </span>
1886
+ <temba-icon
1887
+ class="search-go"
1888
+ name=${Icon.search}
1889
+ size="1.1"
1890
+ clickable
1891
+ title="Search"
1892
+ aria-label="Run search"
1893
+ @click=${this.executeSearch}
1894
+ ></temba-icon>`
1895
+ : this.searchResults.length > 0
1896
+ ? html`<div class="match-pager">
1897
+ <span class="pager-status"
1898
+ >${this.searchIndex + 1} of ${this.searchResults.length}</span
1899
+ >
1900
+ <span
1901
+ class="page-btn ${this.searchFocused &&
1902
+ trimmed === this.lastSearchedQuery
1903
+ ? 'enter-target'
1904
+ : ''}"
1905
+ @click=${this.handleSearchNext}
1906
+ title="Older match (Enter)"
1907
+ aria-label="Older match"
1908
+ >
1909
+ <temba-icon name=${Icon.up} size="1"></temba-icon>
1910
+ </span>
1911
+ <span
1912
+ class="page-btn"
1913
+ @click=${this.handleSearchPrev}
1914
+ title="Newer match (Shift+Enter)"
1915
+ aria-label="Newer match"
1916
+ >
1917
+ <temba-icon name=${Icon.down} size="1"></temba-icon>
1918
+ </span>
1919
+ </div>`
1920
+ : null}
1921
+ <temba-icon
1922
+ class="search-cancel"
1923
+ name=${Icon.close}
1924
+ size="1.1"
1925
+ clickable
1926
+ title="Close search (Escape)"
1927
+ aria-label="Close search"
1928
+ @click=${this.handleSearchClose}
1929
+ ></temba-icon>
1930
+ </div>`;
1931
+ }
1932
+
1568
1933
  public render(): TemplateResult {
1569
1934
  const inFlow = this.currentContact && this.currentContact.flow;
1935
+ const lastSeen = this.getLastSeen();
1570
1936
 
1571
1937
  const inTicket = this.currentTicket;
1572
1938
  const ticketClosed = this.currentTicket && this.currentTicket.closed_on;
1573
1939
 
1574
- return html`<div class="chat-wrapper">
1575
- ${this.currentContact
1576
- ? html`<div class="chat-container">
1577
- ${this.showSearch && this.searchMode
1578
- ? html`<div
1579
- class="search-overlay ${this.searchClosing
1580
- ? 'closing'
1581
- : ''}"
1582
- >
1583
- <div class="search-input-wrapper">
1584
- <temba-textinput
1585
- class="search-input"
1586
- placeholder="Search messages..."
1587
- .value=${this.searchQuery}
1588
- .submitOnEnter=${false}
1589
- @input=${this.handleSearchInput}
1590
- @keydown=${this.handleSearchKeydown}
1591
- @focus=${() => (this.searchFocused = true)}
1592
- @blur=${() => (this.searchFocused = false)}
1593
- widget_only
1594
- ></temba-textinput>
1595
- <button
1596
- class="search-go"
1597
- @click=${this.executeSearch}
1598
- ?disabled=${!this.searchQuery.trim() ||
1599
- this.searchLoading}
1600
- title="Search (Enter)"
1601
- >
1602
- <temba-icon name="search" size="0.8"></temba-icon>
1603
- </button>
1604
- </div>
1605
- ${this.searchLoading
1606
- ? html`<span class="search-counter"
1607
- ><temba-loading size="8"></temba-loading
1608
- ></span>`
1609
- : this.searchResults.length > 0
1610
- ? html`<span class="search-counter"
1611
- >${this.searchIndex + 1} /
1612
- ${this.searchResults.length}</span
1613
- ><button
1614
- class="search-btn ${this.searchFocused &&
1615
- this.searchQuery.trim() === this.lastSearchedQuery
1616
- ? 'enter-target'
1617
- : ''}"
1618
- @click=${this.handleSearchNext}
1619
- title="Older match (Enter)"
1620
- >
1621
- &#x25B2;
1622
- </button>
1623
- <button
1624
- class="search-btn"
1625
- @click=${this.handleSearchPrev}
1626
- title="Newer match (Shift+Enter)"
1627
- >
1628
- &#x25BC;
1629
- </button>`
1630
- : null}
1631
- <button
1632
- class="search-btn"
1633
- @click=${this.handleSearchClose}
1634
- title="Close search (Escape)"
1940
+ return html`${this.currentContact && this.showSearch && this.searchMode
1941
+ ? this.renderSearchBar()
1942
+ : null}
1943
+ <div class="chat-wrapper ${inFlow ? 'in-flow' : ''}">
1944
+ ${this.currentContact
1945
+ ? html`<div class="chat-container">
1946
+ ${this.showSearch && !this.searchMode
1947
+ ? html`<button
1948
+ class="search-toggle"
1949
+ @click=${this.handleSearchToggle}
1950
+ title="Search messages"
1635
1951
  >
1636
- &#x2715;
1637
- </button>
1638
- </div>`
1639
- : null}
1640
- ${this.showSearch && !this.searchMode
1641
- ? html`<button
1642
- class="search-toggle"
1643
- @click=${this.handleSearchToggle}
1644
- title="Search messages"
1645
- >
1646
- <temba-icon name="search" size="1"></temba-icon>
1647
- </button>`
1648
- : null}
1649
- <temba-chat
1650
- @temba-scroll-threshold=${this.fetchPreviousMessages}
1651
- @temba-scroll-threshold-bottom=${this.fetchNewerMessages}
1652
- @temba-fetch-complete=${this.fetchComplete}
1653
- avatar=${this.avatar}
1654
- contactName=${this.currentContact?.name ?? nothing}
1655
- contactUuid=${this.currentContact?.uuid ?? nothing}
1656
- agent
1657
- avatars
1658
- ?hasFooter=${inFlow}
1659
- .showMessageLogsAfter=${this.showMessageLogsAfter}
1660
- >
1661
- ${inFlow
1662
- ? html`
1663
- <div slot="footer" class="flow-footer">
1664
- <div class="in-flow">
1665
- <div class="flow-name">
1666
- <span>Currently in</span>
1667
- <a
1668
- href="/flow/editor/${this.currentContact.flow
1669
- .uuid}/"
1670
- onclick="goto(event, this)"
1671
- ><temba-label
1672
- type="flow"
1673
- clickable
1674
- ?removable=${this.showInterrupt}
1675
- removeLabel=${msg('Interrupt flow')}
1676
- @temba-remove=${this.handleInterrupt}
1677
- >${this.currentContact.flow.name}</temba-label
1678
- ></a
1679
- >
1680
- </div>
1681
- </div>
1682
- </div>
1683
- `
1952
+ <temba-icon name=${Icon.search} size="0.95"></temba-icon>
1953
+ Search
1954
+ </button>`
1684
1955
  : null}
1685
- <div slot="footer"></div>
1686
- </temba-chat>
1687
- ${this.searchNoResults
1688
- ? html`<div class="search-no-results">
1689
- No results found for
1690
- <strong>${this.lastSearchedQuery}</strong>
1956
+ <temba-chat
1957
+ @temba-scroll-threshold=${this.fetchPreviousMessages}
1958
+ @temba-scroll-threshold-bottom=${this.fetchNewerMessages}
1959
+ @temba-fetch-complete=${this.fetchComplete}
1960
+ avatar=${this.avatar}
1961
+ contactName=${this.currentContact?.name ?? nothing}
1962
+ contactUuid=${this.currentContact?.uuid ?? nothing}
1963
+ agent
1964
+ avatars
1965
+ .showMessageLogsAfter=${this.showMessageLogsAfter}
1966
+ >
1967
+ </temba-chat>
1968
+ ${this.searchNoResults
1969
+ ? html`<div class="search-no-results">
1970
+ No results found for
1971
+ <strong>${this.lastSearchedQuery}</strong>
1972
+ </div>`
1973
+ : null}
1974
+ </div>
1975
+ ${inTicket || inFlow || lastSeen
1976
+ ? html`<div class="status-area">
1977
+ ${inTicket
1978
+ ? html`<div class="ticket-controls">
1979
+ <temba-user-select
1980
+ placeholder="Assign to.."
1981
+ searchable
1982
+ searchOnFocus
1983
+ clearable
1984
+ .values=${this.currentTicket.assignee
1985
+ ? [this.currentTicket.assignee]
1986
+ : []}
1987
+ @change=${this.handleAssignmentChanged}
1988
+ ?disabled=${ticketClosed || this.disableAssign}
1989
+ ></temba-user-select>
1990
+
1991
+ <temba-select
1992
+ style="margin:0 0.5em; flex-grow:1"
1993
+ endpoint="/api/v2/topics.json"
1994
+ searchable
1995
+ valuekey="uuid"
1996
+ .values=${[this.currentTicket.topic]}
1997
+ @change=${this.handleTopicChanged}
1998
+ ?disabled=${ticketClosed}
1999
+ ></temba-select>
2000
+
2001
+ ${this.currentTicket.closed_on
2002
+ ? html`
2003
+ <temba-button
2004
+ name="Reopen"
2005
+ @click=${this.handleReopen}
2006
+ ></temba-button>
2007
+ `
2008
+ : html`
2009
+ <temba-button
2010
+ name="Close"
2011
+ destructive
2012
+ @click=${this.handleClose}
2013
+ ></temba-button>
2014
+ `}
2015
+ </div>`
2016
+ : null}
2017
+ ${inFlow || lastSeen
2018
+ ? html`<div class="contact-status">
2019
+ ${lastSeen
2020
+ ? html`<div
2021
+ class="last-seen"
2022
+ title=${lastSeen.title}
2023
+ >
2024
+ ${msg(str`Last seen ${lastSeen.duration}`)}
2025
+ </div>`
2026
+ : null}
2027
+ ${inFlow
2028
+ ? html`<div class="current-flow">
2029
+ <a
2030
+ class="flow-link"
2031
+ href="/flow/editor/${this.currentContact.flow
2032
+ .uuid}/"
2033
+ onclick="goto(event, this)"
2034
+ title=${this.currentContact.flow.name}
2035
+ >
2036
+ <temba-icon name="flow"></temba-icon>
2037
+ <div class="flow-name">
2038
+ ${this.currentContact.flow.name}
2039
+ </div>
2040
+ </a>
2041
+ ${this.showInterrupt
2042
+ ? html`<temba-button
2043
+ small
2044
+ light
2045
+ name=${msg('Interrupt')}
2046
+ @click=${this.handleInterrupt}
2047
+ ></temba-button>`
2048
+ : null}
2049
+ </div>`
2050
+ : null}
2051
+ </div>`
2052
+ : null}
1691
2053
  </div>`
1692
2054
  : null}
1693
- </div>
1694
- ${inTicket
1695
- ? html`<div class="in-ticket-wrapper">
1696
- <div class="in-ticket">
1697
- <temba-user-select
1698
- placeholder="Assign to.."
1699
- searchable
1700
- searchOnFocus
1701
- clearable
1702
- .values=${this.currentTicket.assignee
1703
- ? [this.currentTicket.assignee]
1704
- : []}
1705
- @change=${this.handleAssignmentChanged}
1706
- ?disabled=${ticketClosed || this.disableAssign}
1707
- ></temba-user-select>
1708
-
1709
- <temba-select
1710
- style="margin:0 0.5em; flex-grow:1"
1711
- endpoint="/api/v2/topics.json"
1712
- searchable
1713
- valuekey="uuid"
1714
- .values=${[this.currentTicket.topic]}
1715
- @change=${this.handleTopicChanged}
1716
- ?disabled=${ticketClosed}
1717
- ></temba-select>
1718
-
1719
- ${this.currentTicket.closed_on
1720
- ? html`
1721
- <temba-button
1722
- name="Reopen"
1723
- @click=${this.handleReopen}
1724
- ></temba-button>
1725
- `
1726
- : html`
1727
- <temba-button
1728
- name="Close"
1729
- destructive
1730
- @click=${this.handleClose}
1731
- ></temba-button>
1732
- `}
1733
- </div>
1734
- </div> `
1735
- : null}
1736
- ${this.getTembaCompose()}`
1737
- : null}
1738
- </div>`;
2055
+ ${this.getTembaCompose()}`
2056
+ : null}
2057
+ </div>`;
1739
2058
  }
1740
2059
  }
1741
2060
  export const closeTicket = (uuid: string): Promise<WebResponse> => {