@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.
- package/CHANGELOG.md +21 -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 +2791 -1668
- 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 +22 -17
- package/src/list/ContentList.ts +632 -29
- package/src/list/MsgList.ts +4 -2
- 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/live/ContactChat.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
184
|
-
padding-bottom:
|
|
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:
|
|
193
|
-
padding-bottom:
|
|
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
|
-
.
|
|
257
|
+
.searchbar {
|
|
258
|
+
flex: 0 0 auto;
|
|
204
259
|
display: flex;
|
|
205
260
|
align-items: center;
|
|
206
|
-
gap:
|
|
207
|
-
padding:
|
|
208
|
-
|
|
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
|
-
.
|
|
275
|
+
.searchbar.closing {
|
|
214
276
|
animation: search-slide-out 0.15s ease-in forwards;
|
|
215
277
|
}
|
|
216
278
|
|
|
217
|
-
.
|
|
218
|
-
flex
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
.
|
|
225
|
-
--
|
|
292
|
+
.searchbar input::placeholder {
|
|
293
|
+
color: var(--text-3);
|
|
226
294
|
}
|
|
227
295
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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-
|
|
246
|
-
|
|
306
|
+
.searchbar .search-hint .enter-key {
|
|
307
|
+
position: relative;
|
|
308
|
+
top: 2px;
|
|
247
309
|
}
|
|
248
310
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
.
|
|
287
|
-
|
|
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:
|
|
296
|
-
font-
|
|
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.
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
box-shadow:
|
|
404
|
+
user-select: none;
|
|
405
|
+
box-shadow: var(--shadow-1);
|
|
318
406
|
}
|
|
319
407
|
|
|
320
408
|
.search-toggle:hover {
|
|
321
|
-
background:
|
|
322
|
-
color:
|
|
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
|
-
.
|
|
348
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
366
|
-
/*
|
|
367
|
-
the
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
.
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
412
|
-
.
|
|
541
|
+
.ticket-controls temba-user-select,
|
|
542
|
+
.ticket-controls temba-select {
|
|
413
543
|
--temba-select-min-height: 28px;
|
|
414
544
|
}
|
|
415
545
|
|
|
416
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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="
|
|
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">↵</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
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
▲
|
|
1622
|
-
</button>
|
|
1623
|
-
<button
|
|
1624
|
-
class="search-btn"
|
|
1625
|
-
@click=${this.handleSearchPrev}
|
|
1626
|
-
title="Newer match (Shift+Enter)"
|
|
1627
|
-
>
|
|
1628
|
-
▼
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
<
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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> => {
|