@nyaruka/temba-components 0.170.1 → 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 +24 -0
- package/dist/temba-components.js +763 -703
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/RapidElement.ts +83 -41
- package/src/events/eventRenderers.ts +4 -0
- package/src/flow/Editor.ts +202 -42
- 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/dependencies.ts +115 -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 +137 -97
- package/src/layout/SearchModal.ts +564 -0
- package/src/list/ContentList.ts +7 -1
- package/src/list/FlowList.ts +82 -0
- package/src/list/TembaMenu.ts +71 -13
- package/src/live/ContactChat.ts +247 -70
- package/src/live/ContactWatch.ts +166 -63
- package/src/live/Realtime.ts +155 -4
- 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/AppState.ts +97 -10
- package/src/store/Store.ts +478 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
- package/temba-modules.ts +2 -0
package/src/list/TembaMenu.ts
CHANGED
|
@@ -41,6 +41,10 @@ interface MenuItemState {
|
|
|
41
41
|
collapsed?: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// fields the menu maintains on an item rather than reading off the server, so
|
|
45
|
+
// a reload carries them across instead of treating them as dropped
|
|
46
|
+
const OWN_ITEM_FIELDS = ['items', 'level', 'loading'];
|
|
47
|
+
|
|
44
48
|
const findItem = (
|
|
45
49
|
items: MenuItem[],
|
|
46
50
|
id: string
|
|
@@ -738,8 +742,17 @@ export class TembaMenu extends ResizeElement {
|
|
|
738
742
|
@property({ type: Object })
|
|
739
743
|
pressedItem: MenuItem;
|
|
740
744
|
|
|
741
|
-
|
|
742
|
-
|
|
745
|
+
/**
|
|
746
|
+
* Resolves when every load in flight has settled. Awaiting it has to mean
|
|
747
|
+
* "the menu has finished fetching", not "the last load anyone started has
|
|
748
|
+
* finished" - loads overlap (a click while a debounced refresh is armed),
|
|
749
|
+
* and a caller that awaited whichever promise happened to be here last
|
|
750
|
+
* would read the menu mid-update.
|
|
751
|
+
*/
|
|
752
|
+
public httpComplete: Promise<void> = Promise.resolve();
|
|
753
|
+
|
|
754
|
+
private loadsInFlight = 0;
|
|
755
|
+
private loadsSettled: () => void;
|
|
743
756
|
|
|
744
757
|
root: MenuItem;
|
|
745
758
|
selection: string[] = [];
|
|
@@ -854,22 +867,66 @@ export class TembaMenu extends ResizeElement {
|
|
|
854
867
|
});
|
|
855
868
|
}
|
|
856
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Folds a load into httpComplete, which stays unresolved until the last one
|
|
872
|
+
* outstanding settles.
|
|
873
|
+
*/
|
|
874
|
+
private trackLoad(load: Promise<void>): void {
|
|
875
|
+
if (this.loadsInFlight === 0) {
|
|
876
|
+
this.httpComplete = new Promise((resolve) => {
|
|
877
|
+
this.loadsSettled = resolve;
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
this.loadsInFlight++;
|
|
881
|
+
|
|
882
|
+
load.then(() => {
|
|
883
|
+
if (--this.loadsInFlight === 0) {
|
|
884
|
+
this.loadsSettled();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Reloading a level reuses the item objects it already had. Replacing them
|
|
891
|
+
* would orphan anything already pointing at one - a rendered click handler,
|
|
892
|
+
* or a load in flight for that item's own children, which would then land
|
|
893
|
+
* on a copy no longer in the tree.
|
|
894
|
+
*/
|
|
895
|
+
private mergeItems(item: MenuItem, items: MenuItem[]): MenuItem[] {
|
|
896
|
+
const previous = item.items || [];
|
|
897
|
+
return items.map((newItem) => {
|
|
898
|
+
const prevItem = previous.find((prev) => prev.id == newItem.id);
|
|
899
|
+
if (!prevItem) {
|
|
900
|
+
return newItem;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// sub-items the reload didn't speak to are ours to carry over - either
|
|
904
|
+
// ones we already had or ones still on their way
|
|
905
|
+
const carried = newItem.items || prevItem.items;
|
|
906
|
+
|
|
907
|
+
// anything the reload dropped is gone, so this reads as a replacement
|
|
908
|
+
// everywhere except identity - bar the fields the menu sets on an item
|
|
909
|
+
// itself, which the server never speaks to
|
|
910
|
+
Object.keys(prevItem).forEach((key) => {
|
|
911
|
+
if (!(key in newItem) && !OWN_ITEM_FIELDS.includes(key)) {
|
|
912
|
+
delete prevItem[key];
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
Object.assign(prevItem, newItem);
|
|
916
|
+
if (carried) {
|
|
917
|
+
prevItem.items = carried;
|
|
918
|
+
}
|
|
919
|
+
return prevItem;
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
857
923
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
858
924
|
private loadItems(item: MenuItem, event: MouseEvent | KeyboardEvent = null) {
|
|
859
925
|
if (item && item.endpoint) {
|
|
860
926
|
item.loading = true;
|
|
861
|
-
|
|
927
|
+
const load = fetchResults(item.endpoint)
|
|
862
928
|
.then((items: MenuItem[]) => {
|
|
863
|
-
items.
|
|
864
|
-
if (!newItem.items) {
|
|
865
|
-
const prevItem = (item.items || []).find(
|
|
866
|
-
(prev) => prev.id == newItem.id
|
|
867
|
-
);
|
|
868
|
-
if (prevItem && prevItem.items) {
|
|
869
|
-
newItem.items = prevItem.items;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
});
|
|
929
|
+
items = this.mergeItems(item, items);
|
|
873
930
|
|
|
874
931
|
// update our item level
|
|
875
932
|
items.forEach((subItem) => {
|
|
@@ -901,6 +958,7 @@ export class TembaMenu extends ResizeElement {
|
|
|
901
958
|
.catch((error) => {
|
|
902
959
|
this.fireCustomEvent(CustomEventType.Error, { error });
|
|
903
960
|
});
|
|
961
|
+
this.trackLoad(load);
|
|
904
962
|
}
|
|
905
963
|
}
|
|
906
964
|
|
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;
|
|
@@ -746,6 +749,9 @@ export class ContactChat extends ContactStoreElement {
|
|
|
746
749
|
public connectedCallback() {
|
|
747
750
|
super.connectedCallback();
|
|
748
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();
|
|
749
755
|
this.updateSubscriptions();
|
|
750
756
|
this.stateRefresh = window.setInterval(
|
|
751
757
|
() => this.requestUpdate(),
|
|
@@ -782,6 +788,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
782
788
|
this.fetchMissedEvents();
|
|
783
789
|
}
|
|
784
790
|
this.fetchPreviousMessages();
|
|
791
|
+
this.tryPendingSearch();
|
|
785
792
|
}
|
|
786
793
|
}
|
|
787
794
|
|
|
@@ -830,19 +837,30 @@ export class ContactChat extends ContactStoreElement {
|
|
|
830
837
|
if (!this.subscriptions.has(key)) {
|
|
831
838
|
this.subscriptions.set(
|
|
832
839
|
key,
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
)
|
|
840
858
|
);
|
|
841
859
|
}
|
|
842
860
|
});
|
|
843
861
|
}
|
|
844
862
|
|
|
845
|
-
private handleSocketEvent(event:
|
|
863
|
+
private handleSocketEvent(event: ContactHistoryEvent) {
|
|
846
864
|
if (!this.currentContact) {
|
|
847
865
|
return;
|
|
848
866
|
}
|
|
@@ -864,12 +882,20 @@ export class ContactChat extends ContactStoreElement {
|
|
|
864
882
|
}
|
|
865
883
|
|
|
866
884
|
// typing events are ephemeral indicator state, not history
|
|
867
|
-
if (
|
|
868
|
-
|
|
885
|
+
if (
|
|
886
|
+
event.type === Events.TYPING_STARTED ||
|
|
887
|
+
event.type === Events.TYPING_STOPPED
|
|
888
|
+
) {
|
|
889
|
+
this.handleTypingEvent(event as TypingEvent);
|
|
869
890
|
return;
|
|
870
891
|
}
|
|
871
892
|
|
|
872
|
-
|
|
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
|
+
});
|
|
873
899
|
if (messages.length > 0) {
|
|
874
900
|
this.chat.addMessages(messages, null, true);
|
|
875
901
|
}
|
|
@@ -877,26 +903,13 @@ export class ContactChat extends ContactStoreElement {
|
|
|
877
903
|
|
|
878
904
|
/**
|
|
879
905
|
* Ephemeral events that update contact state rather than record history.
|
|
880
|
-
* They are never persisted, so
|
|
881
|
-
*
|
|
882
|
-
*
|
|
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.
|
|
883
910
|
*/
|
|
884
|
-
private handleContactStateEvent(
|
|
885
|
-
event
|
|
886
|
-
) {
|
|
887
|
-
const contact = this.currentContact;
|
|
888
|
-
if (event.type === Events.CONTACT_LAST_SEEN_CHANGED) {
|
|
889
|
-
const lastSeenOn = (event as ContactLastSeenChangedEvent).last_seen_on;
|
|
890
|
-
// last seen only ever moves forward - ignore out of order deliveries
|
|
891
|
-
if (
|
|
892
|
-
!contact.last_seen_on ||
|
|
893
|
-
new Date(lastSeenOn) > new Date(contact.last_seen_on)
|
|
894
|
-
) {
|
|
895
|
-
contact.last_seen_on = lastSeenOn;
|
|
896
|
-
}
|
|
897
|
-
} else {
|
|
898
|
-
contact.flow = (event as ContactFlowChangedEvent).flow || null;
|
|
899
|
-
}
|
|
911
|
+
private handleContactStateEvent(event: ContactHistoryEvent) {
|
|
912
|
+
applyContactEvent(this.currentContact, event);
|
|
900
913
|
this.requestUpdate();
|
|
901
914
|
}
|
|
902
915
|
|
|
@@ -932,13 +945,18 @@ export class ContactChat extends ContactStoreElement {
|
|
|
932
945
|
return;
|
|
933
946
|
}
|
|
934
947
|
|
|
935
|
-
event
|
|
936
|
-
|
|
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);
|
|
937
955
|
|
|
938
|
-
if (event.type ===
|
|
939
|
-
this.chat.setTyping(
|
|
956
|
+
if (event.type === Events.TYPING_STARTED) {
|
|
957
|
+
this.chat.setTyping(typing);
|
|
940
958
|
} else {
|
|
941
|
-
this.chat.clearTyping(
|
|
959
|
+
this.chat.clearTyping(typing);
|
|
942
960
|
}
|
|
943
961
|
}
|
|
944
962
|
|
|
@@ -1056,22 +1074,44 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1056
1074
|
}
|
|
1057
1075
|
}
|
|
1058
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
|
+
|
|
1059
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
|
+
|
|
1060
1109
|
if (this.searchMode) {
|
|
1061
1110
|
this.handleSearchClose();
|
|
1062
1111
|
} else {
|
|
1063
1112
|
this.searchMode = true;
|
|
1064
|
-
this.
|
|
1065
|
-
this.
|
|
1066
|
-
this.searchIndex = -1;
|
|
1067
|
-
this.searchLoading = false;
|
|
1068
|
-
this.searchNoResults = false;
|
|
1069
|
-
window.setTimeout(() => {
|
|
1070
|
-
const input = this.shadowRoot.querySelector('.search-input') as any;
|
|
1071
|
-
if (input) {
|
|
1072
|
-
input.focus();
|
|
1073
|
-
}
|
|
1074
|
-
}, 50);
|
|
1113
|
+
this.resetSearchState();
|
|
1114
|
+
this.focusSearchInput();
|
|
1075
1115
|
}
|
|
1076
1116
|
}
|
|
1077
1117
|
|
|
@@ -1098,16 +1138,20 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1098
1138
|
// supersede any in-flight match navigation right away — its chain
|
|
1099
1139
|
// must not re-assert highlights over the restored view below
|
|
1100
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;
|
|
1101
1144
|
this.searchClosing = true;
|
|
1102
1145
|
window.setTimeout(() => {
|
|
1103
1146
|
this.searchClosing = false;
|
|
1104
1147
|
this.searchMode = false;
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
this.
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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);
|
|
1111
1155
|
this.restoreUnsearchedView();
|
|
1112
1156
|
}, 150);
|
|
1113
1157
|
}
|
|
@@ -1116,6 +1160,10 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1116
1160
|
const input = e.target as HTMLInputElement;
|
|
1117
1161
|
this.searchQuery = input.value;
|
|
1118
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
|
+
|
|
1119
1167
|
// any edit away from the searched query invalidates its results —
|
|
1120
1168
|
// drop them and put the history back at its unsearched view rather
|
|
1121
1169
|
// than staying parked at a stale match; backspacing to nothing is
|
|
@@ -1139,6 +1187,77 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1139
1187
|
// tracks the query that produced the current searchResults
|
|
1140
1188
|
private lastSearchedQuery = '';
|
|
1141
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
|
+
|
|
1142
1261
|
// bumped whenever the current search is superseded (new search, query
|
|
1143
1262
|
// edit, close) — navigateToResult's async fade/load chain captures the
|
|
1144
1263
|
// value at entry and bails at each step once it goes stale, so a chain
|
|
@@ -1163,10 +1282,11 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1163
1282
|
}
|
|
1164
1283
|
}
|
|
1165
1284
|
|
|
1166
|
-
|
|
1285
|
+
/** Runs the current query, returning whether it was actually started. */
|
|
1286
|
+
private executeSearch(targetEvent: SearchResult = null): boolean {
|
|
1167
1287
|
const query = this.searchQuery.trim();
|
|
1168
1288
|
if (!query || !this.currentContact || this.searchLoading) {
|
|
1169
|
-
return;
|
|
1289
|
+
return false;
|
|
1170
1290
|
}
|
|
1171
1291
|
|
|
1172
1292
|
// a fresh search supersedes any navigation still in flight
|
|
@@ -1182,7 +1302,17 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1182
1302
|
this.chat.reset();
|
|
1183
1303
|
}
|
|
1184
1304
|
|
|
1185
|
-
|
|
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
|
+
|
|
1186
1316
|
getUrl(url)
|
|
1187
1317
|
.then((response: WebResponse) => {
|
|
1188
1318
|
this.searchLoading = false;
|
|
@@ -1198,15 +1328,32 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1198
1328
|
// through the list), so order the results newest-first here rather
|
|
1199
1329
|
// than depending on the endpoint's ordering (uuid v7 sorts
|
|
1200
1330
|
// chronologically, matching the uuid comparisons used elsewhere)
|
|
1201
|
-
|
|
1202
|
-
(
|
|
1203
|
-
).sort((a, b) =>
|
|
1204
|
-
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())
|
|
1205
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
|
+
|
|
1206
1349
|
if (this.searchResults.length > 0) {
|
|
1207
1350
|
this.searchNoResults = false;
|
|
1208
|
-
|
|
1209
|
-
|
|
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);
|
|
1210
1357
|
} else {
|
|
1211
1358
|
this.searchNoResults = true;
|
|
1212
1359
|
this.searchIndex = -1;
|
|
@@ -1221,6 +1368,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1221
1368
|
this.searchIndex = -1;
|
|
1222
1369
|
this.searchNoResults = false;
|
|
1223
1370
|
});
|
|
1371
|
+
|
|
1372
|
+
return true;
|
|
1224
1373
|
}
|
|
1225
1374
|
|
|
1226
1375
|
private navigateToResult(index: number) {
|
|
@@ -1518,6 +1667,23 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1518
1667
|
* second user ref (the assignee) whose avatar feeds the event's hover
|
|
1519
1668
|
* tooltip.
|
|
1520
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
|
+
|
|
1521
1687
|
private resolveUserAvatar(event: any) {
|
|
1522
1688
|
for (const user of [event._user, event.assignee]) {
|
|
1523
1689
|
if (user && user.uuid && this.store) {
|
|
@@ -1638,6 +1804,8 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1638
1804
|
if (this.currentContact) {
|
|
1639
1805
|
const endpoint = this.getEndpoint();
|
|
1640
1806
|
if (!endpoint) {
|
|
1807
|
+
// nothing to fetch — don't leave the chat wedged as fetching
|
|
1808
|
+
chat.fetching = false;
|
|
1641
1809
|
return;
|
|
1642
1810
|
}
|
|
1643
1811
|
|
|
@@ -1649,12 +1817,21 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1649
1817
|
this.afterUUID = anchorUUID;
|
|
1650
1818
|
}
|
|
1651
1819
|
|
|
1820
|
+
const requestedBefore = this.beforeUUID;
|
|
1652
1821
|
fetchContactHistory(
|
|
1653
1822
|
endpoint,
|
|
1654
1823
|
this.currentTicket?.uuid,
|
|
1655
1824
|
this.beforeUUID,
|
|
1656
1825
|
null
|
|
1657
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
|
+
|
|
1658
1835
|
const messages = this.createMessages(page);
|
|
1659
1836
|
messages.reverse();
|
|
1660
1837
|
|
|
@@ -1925,7 +2102,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1925
2102
|
clickable
|
|
1926
2103
|
title="Search"
|
|
1927
2104
|
aria-label="Run search"
|
|
1928
|
-
@click=${this.executeSearch}
|
|
2105
|
+
@click=${() => this.executeSearch()}
|
|
1929
2106
|
></temba-icon>`
|
|
1930
2107
|
: this.searchResults.length > 0
|
|
1931
2108
|
? html`<div class="match-pager">
|