@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/temba-components.js +1015 -747
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/RapidElement.ts +83 -41
  6. package/src/display/Button.ts +14 -2
  7. package/src/display/Chat.ts +41 -22
  8. package/src/display/Label.ts +18 -3
  9. package/src/events/eventRenderers.ts +4 -0
  10. package/src/flow/FlowSearch.ts +12 -392
  11. package/src/flow/actions/send_msg.ts +2 -1
  12. package/src/flow/actions/set_contact_field.ts +1 -0
  13. package/src/flow/actions/set_contact_name.ts +1 -0
  14. package/src/flow/nodes/split_by_ticket.ts +1 -0
  15. package/src/flow/nodes/wait_for_dial.ts +1 -0
  16. package/src/form/RangePicker.ts +10 -2
  17. package/src/form/select/Select.ts +15 -2
  18. package/src/layout/SearchModal.ts +564 -0
  19. package/src/list/BroadcastList.ts +17 -6
  20. package/src/list/ContentList.ts +29 -13
  21. package/src/list/FieldList.ts +8 -1
  22. package/src/list/TembaMenu.ts +71 -13
  23. package/src/live/CampaignEvents.ts +31 -11
  24. package/src/live/ContactChat.ts +276 -76
  25. package/src/live/ContactTimeline.ts +46 -24
  26. package/src/live/ContactWatch.ts +166 -63
  27. package/src/live/Realtime.ts +137 -8
  28. package/src/live/SocketService.ts +115 -10
  29. package/src/live/TicketSearch.ts +290 -0
  30. package/src/live/Watchers.ts +93 -0
  31. package/src/simulator/Simulator.ts +59 -36
  32. package/src/store/Store.ts +17 -34
  33. package/src/styles/designTokens.ts +57 -17
  34. package/src/styles/pillVariants.ts +42 -10
  35. package/static/css/design-system.css +47 -12
  36. package/static/css/temba-components.css +31 -9
  37. package/temba-modules.ts +2 -0
@@ -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;
@@ -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
- color-mix(in srgb, var(--flow, #16a34a) 25%, white);
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
- background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
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: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
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
- background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
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
- subscribeToContactHistory(
811
- topic.contact,
812
- topic.ticket,
813
- (data: any) => this.handleSocketEvent(data),
814
- // on every (re)subscribe fetch anything we might have missed
815
- () => this.fetchMissedEvents()
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: any) {
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 (event.type === 'typing_started' || event.type === 'typing_stopped') {
845
- this.handleTypingEvent(event);
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
- 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
+ });
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 this is the only place they can be applied.
858
- * The current contact is the store's cached copy of the contact, so
859
- * 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.
860
910
  */
861
- private handleContactStateEvent(
862
- event: ContactFlowChangedEvent | ContactLastSeenChangedEvent
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.created_on = new Date(event.created_on);
913
- 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);
914
955
 
915
- if (event.type === 'typing_started') {
916
- this.chat.setTyping(event);
956
+ if (event.type === Events.TYPING_STARTED) {
957
+ this.chat.setTyping(typing);
917
958
  } else {
918
- this.chat.clearTyping(event);
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.searchQuery = '';
1042
- this.searchResults = [];
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
- this.searchQuery = '';
1083
- this.searchResults = [];
1084
- this.searchIndex = -1;
1085
- this.searchLoading = false;
1086
- this.searchNoResults = false;
1087
- 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);
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
- private executeSearch() {
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
- 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
+
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
- this.searchResults = (
1179
- (response.json.results || []) as SearchResult[]
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
- this.searchIndex = 0;
1186
- 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);
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
- background: color-mix(
193
- in srgb,
194
- var(--pill-hue) 12%,
195
- var(--color-widget-bg, #fff)
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
- .campaign-pill:hover {
205
- background: color-mix(
206
- in srgb,
207
- var(--pill-hue) 22%,
208
- var(--color-widget-bg, #fff)
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-mix(
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-mix(
306
- in srgb,
307
- var(--dot-color) 8%,
308
- var(--color-widget-bg, #fff)
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