@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.
@@ -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
- // http promise to monitor for completeness
742
- public httpComplete: Promise<void>;
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
- this.httpComplete = fetchResults(item.endpoint)
927
+ const load = fetchResults(item.endpoint)
862
928
  .then((items: MenuItem[]) => {
863
- items.forEach((newItem) => {
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
 
@@ -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 { subscribeToContactHistory, RealtimeSubscription } from './Realtime';
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
- subscribeToContactHistory(
834
- topic.contact,
835
- topic.ticket,
836
- (data: any) => this.handleSocketEvent(data),
837
- // on every (re)subscribe fetch anything we might have missed
838
- () => this.fetchMissedEvents()
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: any) {
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 (event.type === 'typing_started' || event.type === 'typing_stopped') {
868
- this.handleTypingEvent(event);
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
- const messages = this.createMessages({ events: [event], next: null });
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 this is the only place they can be applied.
881
- * The current contact is the store's cached copy of the contact, so
882
- * updating it in place also keeps the cache fresh for later readers.
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: ContactFlowChangedEvent | ContactLastSeenChangedEvent
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.created_on = new Date(event.created_on);
936
- this.resolveUserAvatar(event);
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 === 'typing_started') {
939
- this.chat.setTyping(event);
956
+ if (event.type === Events.TYPING_STARTED) {
957
+ this.chat.setTyping(typing);
940
958
  } else {
941
- this.chat.clearTyping(event);
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.searchQuery = '';
1065
- this.searchResults = [];
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
- this.searchQuery = '';
1106
- this.searchResults = [];
1107
- this.searchIndex = -1;
1108
- this.searchLoading = false;
1109
- this.searchNoResults = false;
1110
- this.lastSearchedQuery = '';
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
- private executeSearch() {
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
- const url = `/contact/chat_search/${this.currentContact.uuid}/?text=${encodeURIComponent(query)}`;
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
- this.searchResults = (
1202
- (response.json.results || []) as SearchResult[]
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
- this.searchIndex = 0;
1209
- this.navigateToResult(0);
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">