@nyaruka/temba-components 0.171.0 → 0.172.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 +23 -1
- package/dist/temba-components.js +1015 -747
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/RapidElement.ts +83 -41
- package/src/display/Button.ts +14 -2
- package/src/display/Chat.ts +41 -22
- package/src/display/Label.ts +18 -3
- package/src/events/eventRenderers.ts +4 -0
- package/src/flow/FlowSearch.ts +12 -392
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/actions/set_contact_field.ts +1 -0
- package/src/flow/actions/set_contact_name.ts +1 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/flow/nodes/wait_for_dial.ts +1 -0
- package/src/form/RangePicker.ts +10 -2
- package/src/form/select/Select.ts +15 -2
- package/src/layout/SearchModal.ts +564 -0
- package/src/list/BroadcastList.ts +17 -6
- package/src/list/ContentList.ts +29 -13
- package/src/list/FieldList.ts +8 -1
- package/src/list/TembaMenu.ts +71 -13
- package/src/live/CampaignEvents.ts +31 -11
- package/src/live/ContactChat.ts +276 -76
- package/src/live/ContactTimeline.ts +46 -24
- package/src/live/ContactWatch.ts +166 -63
- package/src/live/Realtime.ts +137 -8
- package/src/live/SocketService.ts +115 -10
- package/src/live/TicketSearch.ts +290 -0
- package/src/live/Watchers.ts +93 -0
- package/src/simulator/Simulator.ts +59 -36
- package/src/store/Store.ts +17 -34
- package/src/styles/designTokens.ts +57 -17
- package/src/styles/pillVariants.ts +42 -10
- package/static/css/design-system.css +47 -12
- package/static/css/temba-components.css +31 -9
- package/temba-modules.ts +2 -0
package/src/live/ContactChat.ts
CHANGED
|
@@ -26,17 +26,13 @@ import {
|
|
|
26
26
|
} from '../utils';
|
|
27
27
|
import { ContactStoreElement } from './ContactStoreElement';
|
|
28
28
|
import { Compose, ComposeValue } from '../form/Compose';
|
|
29
|
-
import {
|
|
30
|
-
ContactFlowChangedEvent,
|
|
31
|
-
ContactHistoryPage,
|
|
32
|
-
ContactLastSeenChangedEvent
|
|
33
|
-
} from '../events';
|
|
29
|
+
import { ContactHistoryPage } from '../events';
|
|
34
30
|
import {
|
|
35
31
|
Chat,
|
|
36
32
|
MessageType,
|
|
37
33
|
ContactEvent,
|
|
38
34
|
MsgEvent,
|
|
39
|
-
TypingEvent
|
|
35
|
+
TypingEvent as RenderedTypingEvent
|
|
40
36
|
} from '../display/Chat';
|
|
41
37
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
42
38
|
import { UserSelect } from '../form/select/UserSelect';
|
|
@@ -48,7 +44,13 @@ import {
|
|
|
48
44
|
renderTicketAssigneeChanged
|
|
49
45
|
} from '../events/eventRenderers';
|
|
50
46
|
import { publishToSocket } from './SocketService';
|
|
51
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
ContactHistoryEvent,
|
|
49
|
+
RealtimeSubscription,
|
|
50
|
+
subscribeToContactHistory,
|
|
51
|
+
TypingEvent
|
|
52
|
+
} from './Realtime';
|
|
53
|
+
import { applyContactEvent, watchContact } from './ContactWatch';
|
|
52
54
|
import { Icon } from '../Icons';
|
|
53
55
|
import { designTokens } from '../styles/designTokens';
|
|
54
56
|
import { DateTime } from 'luxon';
|
|
@@ -64,10 +66,11 @@ const STATE_REFRESH_INTERVAL = 60000;
|
|
|
64
66
|
// re-export for backwards compatibility
|
|
65
67
|
export { renderTicketAction, renderTicketAssigneeChanged };
|
|
66
68
|
|
|
67
|
-
interface SearchResult {
|
|
69
|
+
export interface SearchResult {
|
|
68
70
|
uuid: string;
|
|
69
71
|
type: string;
|
|
70
72
|
created_on: string;
|
|
73
|
+
ticket_uuid?: string;
|
|
71
74
|
msg?: any;
|
|
72
75
|
_user?: any;
|
|
73
76
|
[key: string]: any;
|
|
@@ -140,10 +143,18 @@ export class ContactChat extends ContactStoreElement {
|
|
|
140
143
|
|
|
141
144
|
/* While the contact is in a flow the compose outline carries the
|
|
142
145
|
flow hue: resting border matches the flow pill border, focus
|
|
143
|
-
goes solid flow green
|
|
146
|
+
goes solid flow green. The pre-mixed static comes first — a
|
|
147
|
+
custom property can't fall back per-declaration, so the
|
|
148
|
+
derived version needs the @supports gate. */
|
|
144
149
|
.in-flow .compose {
|
|
145
|
-
--compose-border: 1px solid
|
|
146
|
-
|
|
150
|
+
--compose-border: 1px solid #c5e8d2;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
154
|
+
.in-flow .compose {
|
|
155
|
+
--compose-border: 1px solid
|
|
156
|
+
color-mix(in srgb, var(--flow, #16a34a) 25%, white);
|
|
157
|
+
}
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
.in-flow .compose temba-compose {
|
|
@@ -459,10 +470,18 @@ export class ContactChat extends ContactStoreElement {
|
|
|
459
470
|
status-area grey. Fallbacks match the --flow design token for
|
|
460
471
|
pages without tokens. */
|
|
461
472
|
.in-flow .contact-status {
|
|
462
|
-
|
|
473
|
+
/* pre-mixed statics first for browsers without color-mix() */
|
|
474
|
+
background: #e3f4e9;
|
|
463
475
|
color: var(--flow, #16a34a);
|
|
464
476
|
--icon-color: var(--flow, #16a34a);
|
|
465
|
-
border-top-color:
|
|
477
|
+
border-top-color: #c5e8d2;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
481
|
+
.in-flow .contact-status {
|
|
482
|
+
background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
|
|
483
|
+
border-top-color: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
|
|
484
|
+
}
|
|
466
485
|
}
|
|
467
486
|
|
|
468
487
|
/* compact the interrupt button to the row's caption scale */
|
|
@@ -530,7 +549,14 @@ export class ContactChat extends ContactStoreElement {
|
|
|
530
549
|
}
|
|
531
550
|
|
|
532
551
|
.in-flow .chat-box {
|
|
533
|
-
|
|
552
|
+
/* pre-mixed static first for browsers without color-mix() */
|
|
553
|
+
background: #e3f4e9;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
557
|
+
.in-flow .chat-box {
|
|
558
|
+
background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
|
|
559
|
+
}
|
|
534
560
|
}
|
|
535
561
|
|
|
536
562
|
.ticket-controls {
|
|
@@ -723,6 +749,9 @@ export class ContactChat extends ContactStoreElement {
|
|
|
723
749
|
public connectedCallback() {
|
|
724
750
|
super.connectedCallback();
|
|
725
751
|
this.chat = this.shadowRoot.querySelector('temba-chat');
|
|
752
|
+
// a search handed off before we had a chat to run it in only waits on
|
|
753
|
+
// the contact, which may already be loaded
|
|
754
|
+
this.tryPendingSearch();
|
|
726
755
|
this.updateSubscriptions();
|
|
727
756
|
this.stateRefresh = window.setInterval(
|
|
728
757
|
() => this.requestUpdate(),
|
|
@@ -759,6 +788,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
759
788
|
this.fetchMissedEvents();
|
|
760
789
|
}
|
|
761
790
|
this.fetchPreviousMessages();
|
|
791
|
+
this.tryPendingSearch();
|
|
762
792
|
}
|
|
763
793
|
}
|
|
764
794
|
|
|
@@ -807,19 +837,30 @@ export class ContactChat extends ContactStoreElement {
|
|
|
807
837
|
if (!this.subscriptions.has(key)) {
|
|
808
838
|
this.subscriptions.set(
|
|
809
839
|
key,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
840
|
+
// the contact's own channel is owned by the central watcher, which
|
|
841
|
+
// every other contact component on the page shares - we take it as
|
|
842
|
+
// a stream and keep rendering history ourselves. Ticket detail
|
|
843
|
+
// events aren't contact state and stay a direct subscription
|
|
844
|
+
topic.ticket
|
|
845
|
+
? subscribeToContactHistory(
|
|
846
|
+
topic.contact,
|
|
847
|
+
topic.ticket,
|
|
848
|
+
(data: any) => this.handleSocketEvent(data),
|
|
849
|
+
// on every (re)subscribe fetch anything we might have missed
|
|
850
|
+
() => this.fetchMissedEvents()
|
|
851
|
+
)
|
|
852
|
+
: watchContact(
|
|
853
|
+
topic.contact,
|
|
854
|
+
'*',
|
|
855
|
+
(event: any) => this.handleSocketEvent(event),
|
|
856
|
+
() => this.fetchMissedEvents()
|
|
857
|
+
)
|
|
817
858
|
);
|
|
818
859
|
}
|
|
819
860
|
});
|
|
820
861
|
}
|
|
821
862
|
|
|
822
|
-
private handleSocketEvent(event:
|
|
863
|
+
private handleSocketEvent(event: ContactHistoryEvent) {
|
|
823
864
|
if (!this.currentContact) {
|
|
824
865
|
return;
|
|
825
866
|
}
|
|
@@ -841,12 +882,20 @@ export class ContactChat extends ContactStoreElement {
|
|
|
841
882
|
}
|
|
842
883
|
|
|
843
884
|
// typing events are ephemeral indicator state, not history
|
|
844
|
-
if (
|
|
845
|
-
|
|
885
|
+
if (
|
|
886
|
+
event.type === Events.TYPING_STARTED ||
|
|
887
|
+
event.type === Events.TYPING_STOPPED
|
|
888
|
+
) {
|
|
889
|
+
this.handleTypingEvent(event as TypingEvent);
|
|
846
890
|
return;
|
|
847
891
|
}
|
|
848
892
|
|
|
849
|
-
|
|
893
|
+
// createMessages parses the wire event into the rendered form the page
|
|
894
|
+
// type describes, dates and all
|
|
895
|
+
const messages = this.createMessages({
|
|
896
|
+
events: [this.copyWireEvent(event) as unknown as ContactEvent],
|
|
897
|
+
next: null
|
|
898
|
+
});
|
|
850
899
|
if (messages.length > 0) {
|
|
851
900
|
this.chat.addMessages(messages, null, true);
|
|
852
901
|
}
|
|
@@ -854,26 +903,13 @@ export class ContactChat extends ContactStoreElement {
|
|
|
854
903
|
|
|
855
904
|
/**
|
|
856
905
|
* Ephemeral events that update contact state rather than record history.
|
|
857
|
-
* They are never persisted, so
|
|
858
|
-
*
|
|
859
|
-
*
|
|
906
|
+
* They are never persisted, so applying them is the only way they show up.
|
|
907
|
+
* How each one applies is the central watcher's to define - we just run it
|
|
908
|
+
* against our copy, which is the store's cached contact, so applying it in
|
|
909
|
+
* place also keeps the cache fresh for later readers.
|
|
860
910
|
*/
|
|
861
|
-
private handleContactStateEvent(
|
|
862
|
-
event
|
|
863
|
-
) {
|
|
864
|
-
const contact = this.currentContact;
|
|
865
|
-
if (event.type === Events.CONTACT_LAST_SEEN_CHANGED) {
|
|
866
|
-
const lastSeenOn = (event as ContactLastSeenChangedEvent).last_seen_on;
|
|
867
|
-
// last seen only ever moves forward - ignore out of order deliveries
|
|
868
|
-
if (
|
|
869
|
-
!contact.last_seen_on ||
|
|
870
|
-
new Date(lastSeenOn) > new Date(contact.last_seen_on)
|
|
871
|
-
) {
|
|
872
|
-
contact.last_seen_on = lastSeenOn;
|
|
873
|
-
}
|
|
874
|
-
} else {
|
|
875
|
-
contact.flow = (event as ContactFlowChangedEvent).flow || null;
|
|
876
|
-
}
|
|
911
|
+
private handleContactStateEvent(event: ContactHistoryEvent) {
|
|
912
|
+
applyContactEvent(this.currentContact, event);
|
|
877
913
|
this.requestUpdate();
|
|
878
914
|
}
|
|
879
915
|
|
|
@@ -909,13 +945,18 @@ export class ContactChat extends ContactStoreElement {
|
|
|
909
945
|
return;
|
|
910
946
|
}
|
|
911
947
|
|
|
912
|
-
event
|
|
913
|
-
|
|
948
|
+
// the wire event is handed to every subscriber on this channel, so render
|
|
949
|
+
// into our own copy rather than parsing its date in place
|
|
950
|
+
const typing = {
|
|
951
|
+
...this.copyWireEvent(event),
|
|
952
|
+
created_on: new Date(event.created_on)
|
|
953
|
+
} as RenderedTypingEvent;
|
|
954
|
+
this.resolveUserAvatar(typing);
|
|
914
955
|
|
|
915
|
-
if (event.type ===
|
|
916
|
-
this.chat.setTyping(
|
|
956
|
+
if (event.type === Events.TYPING_STARTED) {
|
|
957
|
+
this.chat.setTyping(typing);
|
|
917
958
|
} else {
|
|
918
|
-
this.chat.clearTyping(
|
|
959
|
+
this.chat.clearTyping(typing);
|
|
919
960
|
}
|
|
920
961
|
}
|
|
921
962
|
|
|
@@ -1033,22 +1074,44 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1033
1074
|
}
|
|
1034
1075
|
}
|
|
1035
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Clears the state of the current search - its query, results and the
|
|
1079
|
+
* flags driving the search bar. Opening a fresh search keeps whatever
|
|
1080
|
+
* lastSearchedQuery was, so its results can be re-run; closing or
|
|
1081
|
+
* starting a new one drops it too.
|
|
1082
|
+
*/
|
|
1083
|
+
private resetSearchState(query = '', clearLastSearched = false) {
|
|
1084
|
+
this.searchQuery = query;
|
|
1085
|
+
this.searchResults = [];
|
|
1086
|
+
this.searchIndex = -1;
|
|
1087
|
+
this.searchLoading = false;
|
|
1088
|
+
this.searchNoResults = false;
|
|
1089
|
+
if (clearLastSearched) {
|
|
1090
|
+
this.lastSearchedQuery = '';
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Focuses the search input once the search bar has rendered. */
|
|
1095
|
+
private focusSearchInput() {
|
|
1096
|
+
window.setTimeout(() => {
|
|
1097
|
+
const input = this.shadowRoot.querySelector('.search-input') as any;
|
|
1098
|
+
if (input) {
|
|
1099
|
+
input.focus();
|
|
1100
|
+
}
|
|
1101
|
+
}, 50);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1036
1104
|
private handleSearchToggle() {
|
|
1105
|
+
// an opening or closing search supersedes any hand-off still waiting
|
|
1106
|
+
// on a contact to load
|
|
1107
|
+
this.pendingSearch = null;
|
|
1108
|
+
|
|
1037
1109
|
if (this.searchMode) {
|
|
1038
1110
|
this.handleSearchClose();
|
|
1039
1111
|
} else {
|
|
1040
1112
|
this.searchMode = true;
|
|
1041
|
-
this.
|
|
1042
|
-
this.
|
|
1043
|
-
this.searchIndex = -1;
|
|
1044
|
-
this.searchLoading = false;
|
|
1045
|
-
this.searchNoResults = false;
|
|
1046
|
-
window.setTimeout(() => {
|
|
1047
|
-
const input = this.shadowRoot.querySelector('.search-input') as any;
|
|
1048
|
-
if (input) {
|
|
1049
|
-
input.focus();
|
|
1050
|
-
}
|
|
1051
|
-
}, 50);
|
|
1113
|
+
this.resetSearchState();
|
|
1114
|
+
this.focusSearchInput();
|
|
1052
1115
|
}
|
|
1053
1116
|
}
|
|
1054
1117
|
|
|
@@ -1075,16 +1138,20 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1075
1138
|
// supersede any in-flight match navigation right away — its chain
|
|
1076
1139
|
// must not re-assert highlights over the restored view below
|
|
1077
1140
|
this.searchGeneration++;
|
|
1141
|
+
// and a hand-off waiting on a contact must not reopen search behind
|
|
1142
|
+
// the user once it lands
|
|
1143
|
+
this.pendingSearch = null;
|
|
1078
1144
|
this.searchClosing = true;
|
|
1079
1145
|
window.setTimeout(() => {
|
|
1080
1146
|
this.searchClosing = false;
|
|
1081
1147
|
this.searchMode = false;
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
this.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1148
|
+
// if a hand-off forced the search bar onto a conversation the host
|
|
1149
|
+
// had opted out of, put the host's choice back
|
|
1150
|
+
if (this.forcedShowSearch) {
|
|
1151
|
+
this.forcedShowSearch = false;
|
|
1152
|
+
this.showSearch = false;
|
|
1153
|
+
}
|
|
1154
|
+
this.resetSearchState('', true);
|
|
1088
1155
|
this.restoreUnsearchedView();
|
|
1089
1156
|
}, 150);
|
|
1090
1157
|
}
|
|
@@ -1093,6 +1160,10 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1093
1160
|
const input = e.target as HTMLInputElement;
|
|
1094
1161
|
this.searchQuery = input.value;
|
|
1095
1162
|
|
|
1163
|
+
// the user typing their own query supersedes any hand-off still
|
|
1164
|
+
// waiting on a contact to load
|
|
1165
|
+
this.pendingSearch = null;
|
|
1166
|
+
|
|
1096
1167
|
// any edit away from the searched query invalidates its results —
|
|
1097
1168
|
// drop them and put the history back at its unsearched view rather
|
|
1098
1169
|
// than staying parked at a stale match; backspacing to nothing is
|
|
@@ -1116,6 +1187,77 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1116
1187
|
// tracks the query that produced the current searchResults
|
|
1117
1188
|
private lastSearchedQuery = '';
|
|
1118
1189
|
|
|
1190
|
+
// a search requested (e.g. from the cross-ticket search modal) before the
|
|
1191
|
+
// contact had loaded — executed once that contact and the chat are ready
|
|
1192
|
+
private pendingSearch: {
|
|
1193
|
+
query: string;
|
|
1194
|
+
event: SearchResult;
|
|
1195
|
+
contactUuid: string;
|
|
1196
|
+
} = null;
|
|
1197
|
+
|
|
1198
|
+
// whether startSearch turned showSearch on for a hand-off, so closing the
|
|
1199
|
+
// search can restore the host's opt-out
|
|
1200
|
+
private forcedShowSearch = false;
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Opens search mode and executes the given query, landing on the given
|
|
1204
|
+
* event — which is inserted into the results if the endpoint's matches
|
|
1205
|
+
* don't reach back far enough to include it. Safe to call before the
|
|
1206
|
+
* contact has finished loading — the search runs once it has.
|
|
1207
|
+
*/
|
|
1208
|
+
public startSearch(query: string, event: SearchResult = null): void {
|
|
1209
|
+
if (!query || !query.trim()) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
this.searchMode = true;
|
|
1213
|
+
// hosts only turn search on for some conversations (e.g. once a contact
|
|
1214
|
+
// has been seen), but a hand-off always needs the bar - it carries the
|
|
1215
|
+
// match stepper and the only way back out of the searched view. Track
|
|
1216
|
+
// when we forced it so closing the search restores the host's choice
|
|
1217
|
+
if (!this.showSearch) {
|
|
1218
|
+
this.showSearch = true;
|
|
1219
|
+
this.forcedShowSearch = true;
|
|
1220
|
+
}
|
|
1221
|
+
this.resetSearchState(query, true);
|
|
1222
|
+
// the contact the host has asked us to show, which may still be
|
|
1223
|
+
// loading — the search belongs to it and nobody else
|
|
1224
|
+
this.pendingSearch = {
|
|
1225
|
+
query: query.trim(),
|
|
1226
|
+
event,
|
|
1227
|
+
contactUuid: this.contact
|
|
1228
|
+
};
|
|
1229
|
+
this.focusSearchInput();
|
|
1230
|
+
this.tryPendingSearch();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private tryPendingSearch(): void {
|
|
1234
|
+
const pending = this.pendingSearch;
|
|
1235
|
+
if (!pending || !this.currentContact || !this.chat) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// the search was requested for a specific contact — hold it while that
|
|
1240
|
+
// contact is still loading, but drop it once the host has moved on to
|
|
1241
|
+
// another one rather than running it against the wrong history
|
|
1242
|
+
if (
|
|
1243
|
+
pending.contactUuid &&
|
|
1244
|
+
pending.contactUuid !== this.currentContact.uuid
|
|
1245
|
+
) {
|
|
1246
|
+
if (pending.contactUuid !== this.contact) {
|
|
1247
|
+
this.pendingSearch = null;
|
|
1248
|
+
}
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// executeSearch declines while another search is still in flight — keep
|
|
1253
|
+
// the hand-off queued in that case so a later update can run it rather
|
|
1254
|
+
// than dropping it on the floor
|
|
1255
|
+
this.searchQuery = pending.query;
|
|
1256
|
+
if (this.executeSearch(pending.event)) {
|
|
1257
|
+
this.pendingSearch = null;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1119
1261
|
// bumped whenever the current search is superseded (new search, query
|
|
1120
1262
|
// edit, close) — navigateToResult's async fade/load chain captures the
|
|
1121
1263
|
// value at entry and bails at each step once it goes stale, so a chain
|
|
@@ -1140,10 +1282,11 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1140
1282
|
}
|
|
1141
1283
|
}
|
|
1142
1284
|
|
|
1143
|
-
|
|
1285
|
+
/** Runs the current query, returning whether it was actually started. */
|
|
1286
|
+
private executeSearch(targetEvent: SearchResult = null): boolean {
|
|
1144
1287
|
const query = this.searchQuery.trim();
|
|
1145
1288
|
if (!query || !this.currentContact || this.searchLoading) {
|
|
1146
|
-
return;
|
|
1289
|
+
return false;
|
|
1147
1290
|
}
|
|
1148
1291
|
|
|
1149
1292
|
// a fresh search supersedes any navigation still in flight
|
|
@@ -1159,7 +1302,17 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1159
1302
|
this.chat.reset();
|
|
1160
1303
|
}
|
|
1161
1304
|
|
|
1162
|
-
|
|
1305
|
+
// a ticket-scoped chat only shows this ticket's history, so its search
|
|
1306
|
+
// only covers this ticket's messages too. The endpoint narrows the
|
|
1307
|
+
// search to ticket messages and drops the other tickets' matches
|
|
1308
|
+
// itself, so its cap is no longer spent on messages this view can't
|
|
1309
|
+
// show — matches from the contact's other tickets can still crowd out
|
|
1310
|
+
// this one's, so the cap isn't per-ticket
|
|
1311
|
+
let url = `/contact/chat_search/${this.currentContact.uuid}/?text=${encodeURIComponent(query)}`;
|
|
1312
|
+
if (this.currentTicket) {
|
|
1313
|
+
url += `&ticket=${encodeURIComponent(this.currentTicket.uuid)}`;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1163
1316
|
getUrl(url)
|
|
1164
1317
|
.then((response: WebResponse) => {
|
|
1165
1318
|
this.searchLoading = false;
|
|
@@ -1175,15 +1328,32 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1175
1328
|
// through the list), so order the results newest-first here rather
|
|
1176
1329
|
// than depending on the endpoint's ordering (uuid v7 sorts
|
|
1177
1330
|
// chronologically, matching the uuid comparisons used elsewhere)
|
|
1178
|
-
|
|
1179
|
-
(
|
|
1180
|
-
).sort((a, b) =>
|
|
1181
|
-
b.uuid.toLowerCase().localeCompare(a.uuid.toLowerCase())
|
|
1331
|
+
const results = ((response.json.results || []) as SearchResult[]).sort(
|
|
1332
|
+
(a, b) => b.uuid.toLowerCase().localeCompare(a.uuid.toLowerCase())
|
|
1182
1333
|
);
|
|
1334
|
+
|
|
1335
|
+
// the endpoint caps how many matches it returns, so the event we
|
|
1336
|
+
// were handed off may not be among them — splice it into its own
|
|
1337
|
+
// spot in the ordering so the hand-off always lands on it
|
|
1338
|
+
if (targetEvent && !results.some((r) => r.uuid === targetEvent.uuid)) {
|
|
1339
|
+
const older = results.findIndex(
|
|
1340
|
+
(r) =>
|
|
1341
|
+
r.uuid
|
|
1342
|
+
.toLowerCase()
|
|
1343
|
+
.localeCompare(targetEvent.uuid.toLowerCase()) < 0
|
|
1344
|
+
);
|
|
1345
|
+
results.splice(older === -1 ? results.length : older, 0, targetEvent);
|
|
1346
|
+
}
|
|
1347
|
+
this.searchResults = results;
|
|
1348
|
+
|
|
1183
1349
|
if (this.searchResults.length > 0) {
|
|
1184
1350
|
this.searchNoResults = false;
|
|
1185
|
-
|
|
1186
|
-
|
|
1351
|
+
const targetIndex = targetEvent
|
|
1352
|
+
? this.searchResults.findIndex((r) => r.uuid === targetEvent.uuid)
|
|
1353
|
+
: -1;
|
|
1354
|
+
const index = targetIndex !== -1 ? targetIndex : 0;
|
|
1355
|
+
this.searchIndex = index;
|
|
1356
|
+
this.navigateToResult(index);
|
|
1187
1357
|
} else {
|
|
1188
1358
|
this.searchNoResults = true;
|
|
1189
1359
|
this.searchIndex = -1;
|
|
@@ -1198,6 +1368,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1198
1368
|
this.searchIndex = -1;
|
|
1199
1369
|
this.searchNoResults = false;
|
|
1200
1370
|
});
|
|
1371
|
+
|
|
1372
|
+
return true;
|
|
1201
1373
|
}
|
|
1202
1374
|
|
|
1203
1375
|
private navigateToResult(index: number) {
|
|
@@ -1495,6 +1667,23 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1495
1667
|
* second user ref (the assignee) whose avatar feeds the event's hover
|
|
1496
1668
|
* tooltip.
|
|
1497
1669
|
*/
|
|
1670
|
+
/**
|
|
1671
|
+
* A copy of a wire event we can parse and resolve into. The event object is
|
|
1672
|
+
* handed to every subscriber on the channel, and resolveUserAvatar writes an
|
|
1673
|
+
* avatar onto the user objects hanging off it, so a shallow spread isn't
|
|
1674
|
+
* enough on its own - those ride through it aliased.
|
|
1675
|
+
*/
|
|
1676
|
+
private copyWireEvent(event: any): any {
|
|
1677
|
+
const copy = { ...event };
|
|
1678
|
+
if (copy._user) {
|
|
1679
|
+
copy._user = { ...copy._user };
|
|
1680
|
+
}
|
|
1681
|
+
if (copy.assignee) {
|
|
1682
|
+
copy.assignee = { ...copy.assignee };
|
|
1683
|
+
}
|
|
1684
|
+
return copy;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1498
1687
|
private resolveUserAvatar(event: any) {
|
|
1499
1688
|
for (const user of [event._user, event.assignee]) {
|
|
1500
1689
|
if (user && user.uuid && this.store) {
|
|
@@ -1615,6 +1804,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1615
1804
|
if (this.currentContact) {
|
|
1616
1805
|
const endpoint = this.getEndpoint();
|
|
1617
1806
|
if (!endpoint) {
|
|
1807
|
+
// nothing to fetch — don't leave the chat wedged as fetching
|
|
1808
|
+
chat.fetching = false;
|
|
1618
1809
|
return;
|
|
1619
1810
|
}
|
|
1620
1811
|
|
|
@@ -1626,12 +1817,21 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1626
1817
|
this.afterUUID = anchorUUID;
|
|
1627
1818
|
}
|
|
1628
1819
|
|
|
1820
|
+
const requestedBefore = this.beforeUUID;
|
|
1629
1821
|
fetchContactHistory(
|
|
1630
1822
|
endpoint,
|
|
1631
1823
|
this.currentTicket?.uuid,
|
|
1632
1824
|
this.beforeUUID,
|
|
1633
1825
|
null
|
|
1634
1826
|
).then((page: ContactHistoryPage) => {
|
|
1827
|
+
// the view was repositioned while this page was in flight (e.g. a
|
|
1828
|
+
// search navigated to a match and re-anchored the history) — drop
|
|
1829
|
+
// it rather than stacking the old view's messages on the new one
|
|
1830
|
+
if (this.beforeUUID !== requestedBefore) {
|
|
1831
|
+
chat.fetching = false;
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1635
1835
|
const messages = this.createMessages(page);
|
|
1636
1836
|
messages.reverse();
|
|
1637
1837
|
|
|
@@ -1902,7 +2102,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1902
2102
|
clickable
|
|
1903
2103
|
title="Search"
|
|
1904
2104
|
aria-label="Run search"
|
|
1905
|
-
@click=${this.executeSearch}
|
|
2105
|
+
@click=${() => this.executeSearch()}
|
|
1906
2106
|
></temba-icon>`
|
|
1907
2107
|
: this.searchResults.length > 0
|
|
1908
2108
|
? html`<div class="match-pager">
|
|
@@ -189,24 +189,36 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
189
189
|
padding: 0.05em 0.65em;
|
|
190
190
|
border-radius: 999px;
|
|
191
191
|
white-space: nowrap;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
197
|
-
border: 1px solid
|
|
198
|
-
color-mix(in srgb, var(--pill-hue) 25%, var(--color-widget-bg, #fff));
|
|
192
|
+
/* plain bg / neutral border for browsers without color-mix();
|
|
193
|
+
the hue-tinted versions live in the @supports block below */
|
|
194
|
+
background: var(--color-widget-bg, #fff);
|
|
195
|
+
border: 1px solid var(--pill-hue);
|
|
199
196
|
color: var(--pill-hue);
|
|
200
197
|
cursor: pointer;
|
|
201
198
|
transition: background 100ms ease-in-out;
|
|
202
199
|
}
|
|
203
200
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
202
|
+
.campaign-pill {
|
|
203
|
+
background: color-mix(
|
|
204
|
+
in srgb,
|
|
205
|
+
var(--pill-hue) 12%,
|
|
206
|
+
var(--color-widget-bg, #fff)
|
|
207
|
+
);
|
|
208
|
+
border: 1px solid
|
|
209
|
+
color-mix(
|
|
210
|
+
in srgb,
|
|
211
|
+
var(--pill-hue) 25%,
|
|
212
|
+
var(--color-widget-bg, #fff)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
.campaign-pill:hover {
|
|
216
|
+
background: color-mix(
|
|
217
|
+
in srgb,
|
|
218
|
+
var(--pill-hue) 22%,
|
|
219
|
+
var(--color-widget-bg, #fff)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
210
222
|
}
|
|
211
223
|
|
|
212
224
|
.campaign-pill:focus-visible {
|
|
@@ -289,24 +301,34 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
289
301
|
}
|
|
290
302
|
|
|
291
303
|
/* past events are filled with a lighter tint of the same hue and keep
|
|
292
|
-
the solid border - they're settled, whether real or projected
|
|
304
|
+
the solid border - they're settled, whether real or projected.
|
|
305
|
+
Plain widget bg first for browsers without color-mix(). */
|
|
293
306
|
.dot.past {
|
|
294
|
-
background: color-
|
|
295
|
-
in srgb,
|
|
296
|
-
var(--dot-color) 25%,
|
|
297
|
-
var(--color-widget-bg, #fff)
|
|
298
|
-
);
|
|
307
|
+
background: var(--color-widget-bg, #fff);
|
|
299
308
|
}
|
|
300
309
|
|
|
301
310
|
/* future events haven't occurred yet - signal that with a dotted
|
|
302
311
|
border and the faintest tint of the hue inside */
|
|
303
312
|
.dot.future {
|
|
304
313
|
border-style: dotted;
|
|
305
|
-
background: color-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
background: var(--color-widget-bg, #fff);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
318
|
+
.dot.past {
|
|
319
|
+
background: color-mix(
|
|
320
|
+
in srgb,
|
|
321
|
+
var(--dot-color) 25%,
|
|
322
|
+
var(--color-widget-bg, #fff)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
.dot.future {
|
|
326
|
+
background: color-mix(
|
|
327
|
+
in srgb,
|
|
328
|
+
var(--dot-color) 8%,
|
|
329
|
+
var(--color-widget-bg, #fff)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
310
332
|
}
|
|
311
333
|
|
|
312
334
|
/* the now dot and label both paint with the SPA content background
|