@nyaruka/temba-components 0.168.1 → 0.170.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.
@@ -1,5 +1,6 @@
1
1
  import { html, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
+ import { msg } from '@lit/localize';
3
4
  import { TembaList } from './TembaList';
4
5
  import { Contact } from '../interfaces';
5
6
  import { Icon } from '../Icons';
@@ -9,15 +10,17 @@ export class TicketList extends TembaList {
9
10
  agent = '';
10
11
 
11
12
  public getRefreshEndpoint() {
12
- if (this.items.length > 0) {
13
- const lastActivity = this.items[0].ticket.last_activity_on;
13
+ // open tickets sort above closed ones, so the newest activity can be
14
+ // anywhere in the list, not just the top. Skip anything unparseable so a
15
+ // single bad timestamp can't poison the cursor with NaN
16
+ const lastActivity = this.items.reduce((newest, item) => {
17
+ const activity = new Date(item.ticket.last_activity_on).getTime();
18
+ return isNaN(activity) ? newest : Math.max(newest, activity);
19
+ }, 0);
20
+
21
+ if (lastActivity > 0) {
14
22
  const separator = this.endpoint.includes('?') ? '&' : '?';
15
- return (
16
- this.endpoint +
17
- separator +
18
- 'after=' +
19
- new Date(lastActivity).getTime() * 1000
20
- );
23
+ return this.endpoint + separator + 'after=' + lastActivity * 1000;
21
24
  }
22
25
  return this.endpoint;
23
26
  }
@@ -35,10 +38,79 @@ export class TicketList extends TembaList {
35
38
  super();
36
39
 
37
40
  this.valueKey = 'ticket.uuid';
38
- this.renderOption = (contact: Contact): TemplateResult => {
41
+ // the refresh feed is served oldest first - the inherited reverseRefresh
42
+ // keeps mostRecentItem meaning the newest fetched item (compareItems
43
+ // re-sorts the merged list immediately anyway)
44
+
45
+ this.compareItems = (a: Contact, b: Contact): number => {
46
+ const aClosed = !!a.ticket.closed_on;
47
+ const bClosed = !!b.ticket.closed_on;
48
+ if (aClosed !== bClosed) {
49
+ return aClosed ? 1 : -1;
50
+ }
51
+ return (
52
+ new Date(b.ticket.last_activity_on).getTime() -
53
+ new Date(a.ticket.last_activity_on).getTime()
54
+ );
55
+ };
56
+ // a quiet label marks where the list crosses from open into closed
57
+ // tickets - the rows themselves then only need gentle muting
58
+ this.renderDivider = (
59
+ prev: Contact,
60
+ contact: Contact
61
+ ): TemplateResult | null => {
62
+ if (!contact.ticket.closed_on || (prev && prev.ticket.closed_on)) {
63
+ return null;
64
+ }
65
+ const closed = msg('Closed');
66
+ return html`
67
+ <div
68
+ role="separator"
69
+ aria-label=${closed}
70
+ style="display:flex; align-items:center; gap:0.6em; padding:0.9em var(--pad) 0.2em var(--pad);"
71
+ >
72
+ <div
73
+ style="font-size:0.7em; font-weight:500; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-4);"
74
+ >
75
+ ${closed}
76
+ </div>
77
+ <div
78
+ style="flex-grow:1; height:1px; background:var(--color-widget-border);"
79
+ ></div>
80
+ </div>
81
+ `;
82
+ };
83
+ this.renderOption = (
84
+ contact: Contact,
85
+ selected: boolean
86
+ ): TemplateResult => {
87
+ // closed tickets are settled history - a compact single line that
88
+ // recedes behind the open work above it
89
+ if (contact.ticket.closed_on) {
90
+ return html`
91
+ <div
92
+ style="display:flex; align-items:baseline; margin-top:0.1em; margin-bottom:0.1em; ${selected
93
+ ? ''
94
+ : 'color:var(--text-3);'}"
95
+ >
96
+ <div
97
+ style="flex:1; min-width:0; font-weight:400; line-height:1.6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"
98
+ >
99
+ ${contact.name}
100
+ </div>
101
+ <div style="font-size:0.8em; margin-left:0.75em;">
102
+ <temba-date
103
+ value=${contact.ticket.closed_on}
104
+ display="duration"
105
+ ></temba-date>
106
+ </div>
107
+ </div>
108
+ `;
109
+ }
110
+
39
111
  return html`
40
112
  <div
41
- style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em"
113
+ style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em;"
42
114
  >
43
115
  <div
44
116
  style="display:flex; align-items: flex-start;border:0px solid red;"
@@ -49,10 +121,8 @@ export class TicketList extends TembaList {
49
121
  >
50
122
  ${contact.name}
51
123
  </div>
52
- ${contact.ticket.closed_on
53
- ? null
54
- : contact.last_msg
55
- ? html`
124
+ ${contact.last_msg
125
+ ? html`
56
126
  <div
57
127
  style="font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;"
58
128
  >
@@ -81,13 +151,13 @@ export class TicketList extends TembaList {
81
151
  }
82
152
  </div></div>
83
153
  `
84
- : null}
154
+ : null}
85
155
  </div>
86
156
  <div
87
157
  style="margin-right: -5px; margin-top: 0px;display:flex;flex-direction:column;align-items:flex-end;max-width:60px;min-width:30px;border:0px solid green;text-align:right"
88
158
  >
89
159
  <div>
90
- ${!contact.ticket.closed_on && contact.ticket.assignee
160
+ ${contact.ticket.assignee
91
161
  ? html`<temba-user
92
162
  name=${contact.ticket.assignee.name}
93
163
  email=${contact.ticket.assignee.email}
@@ -101,8 +171,7 @@ export class TicketList extends TembaList {
101
171
 
102
172
  <div style="font-size:0.8em;text-align:right;border:0px solid red;">
103
173
  <temba-date
104
- value=${contact.ticket.closed_on ||
105
- contact.ticket.last_activity_on}
174
+ value=${contact.ticket.last_activity_on}
106
175
  display="duration"
107
176
  ></temba-date>
108
177
  </div>
@@ -74,6 +74,12 @@ interface SearchResult {
74
74
  }
75
75
 
76
76
  export class ContactChat extends ContactStoreElement {
77
+ // we run our own firehose subscription below (history rendering, typing,
78
+ // ephemeral state) with its own contact/ticket channel choreography, so we
79
+ // opt out of the central watcher entirely - our data loads and syncs
80
+ // through the store as before
81
+ protected watchTypes: string[] = null;
82
+
77
83
  public static get styles() {
78
84
  return css`
79
85
  ${designTokens}
@@ -877,9 +883,15 @@ export class ContactChat extends ContactStoreElement {
877
883
  return null;
878
884
  }
879
885
 
886
+ // unparseable values and zero-value times (e.g. go's zero time for a
887
+ // contact that has never been seen) mean there's no last seen to show
888
+ const lastSeen = DateTime.fromISO(lastSeenOn);
889
+ if (!lastSeen.isValid || lastSeen.year <= 1) {
890
+ return null;
891
+ }
892
+
880
893
  // recent activity speaks for itself in the chat - only surface last
881
894
  // seen once the contact has been quiet for at least an hour
882
- const lastSeen = DateTime.fromISO(lastSeenOn);
883
895
  const minutes = DateTime.now().diff(lastSeen, 'minutes').minutes;
884
896
  if (minutes < 60) {
885
897
  return null;
@@ -1,13 +1,14 @@
1
1
  import { css, html, PropertyValues, TemplateResult } from 'lit';
2
2
  import { property, state } from 'lit/decorators.js';
3
3
  import { Icon } from '../Icons';
4
- import { CustomEventType, Group, URN } from '../interfaces';
4
+ import { Contact, CustomEventType, Group, URN } from '../interfaces';
5
5
  import { getLanguageName } from '../languages';
6
6
  import { capitalize, WebResponse } from '../utils';
7
7
  import { Select } from '../form/select/Select';
8
8
  import { TextInput } from '../form/TextInput';
9
9
  import { ContactFieldEditor } from './ContactFieldEditor';
10
10
  import { ContactStoreElement, getDestinationURN } from './ContactStoreElement';
11
+ import { Events } from '../events/eventRenderers';
11
12
 
12
13
  interface Option {
13
14
  name: string;
@@ -22,6 +23,16 @@ const STATUS_OPTIONS: Option[] = [
22
23
  ];
23
24
 
24
25
  export class ContactDetails extends ContactStoreElement {
26
+ // the contact state we render - live changes to any of these reach us
27
+ // through the central watcher
28
+ protected watchTypes = [
29
+ Events.CONTACT_NAME_CHANGED,
30
+ Events.CONTACT_URNS_CHANGED,
31
+ Events.CONTACT_LANGUAGE_CHANGED,
32
+ Events.CONTACT_STATUS_CHANGED,
33
+ Events.CONTACT_GROUPS_CHANGED
34
+ ];
35
+
25
36
  @property({ type: Boolean })
26
37
  editable = false;
27
38
 
@@ -430,6 +441,26 @@ export class ContactDetails extends ContactStoreElement {
430
441
  }
431
442
  }
432
443
 
444
+ /**
445
+ * A watcher delivery carries server state from before the writes we still
446
+ * have in flight, so keep our own value for anything mid-save - the save
447
+ * itself applies the server's version when it lands.
448
+ */
449
+ protected mergeWatchedContact(contact: Contact, previous: Contact): Contact {
450
+ if (!previous || this.fieldSaveGenerations.size === 0) {
451
+ return contact;
452
+ }
453
+
454
+ const merged = { ...contact };
455
+ this.fieldSaveGenerations.forEach((generation, field) => {
456
+ if (field in previous) {
457
+ const value = previous[field];
458
+ merged[field] = Array.isArray(value) ? [...value] : value;
459
+ }
460
+ });
461
+ return merged;
462
+ }
463
+
433
464
  public willUpdate(changed: PropertyValues): void {
434
465
  if (changed.has('contact') || changed.has('endpoint')) {
435
466
  const url = this.contact ? `${this.endpoint}${this.contact}` : null;
@@ -10,11 +10,16 @@ import { getClasses, postJSON } from '../utils';
10
10
  import { ContactFieldEditor } from './ContactFieldEditor';
11
11
  import { ContactStoreElement } from './ContactStoreElement';
12
12
  import { Checkbox } from '../form/Checkbox';
13
- import { ContactField, CustomEventType } from '../interfaces';
13
+ import { Contact, ContactField, CustomEventType } from '../interfaces';
14
+ import { Events } from '../events/eventRenderers';
14
15
 
15
16
  const MIN_FOR_FILTER = 10;
16
17
 
17
18
  export class ContactFields extends ContactStoreElement {
19
+ // we render field values, so field changes are the only events we need -
20
+ // the central watcher hands us a refetched contact when they happen
21
+ protected watchTypes = [Events.CONTACT_FIELD_CHANGED];
22
+
18
23
  static get styles() {
19
24
  return css`
20
25
  .field {
@@ -154,6 +159,34 @@ export class ContactFields extends ContactStoreElement {
154
159
  );
155
160
  }
156
161
 
162
+ /**
163
+ * A watcher delivery carries server state from before an edit the user is
164
+ * still making, so keep the value we already have for any field whose
165
+ * editor is dirty - rebinding it would drop what they typed.
166
+ */
167
+ protected mergeWatchedContact(contact: Contact, previous: Contact): Contact {
168
+ if (!previous || !previous.fields) {
169
+ return contact;
170
+ }
171
+
172
+ const editors = Array.from(
173
+ this.shadowRoot?.querySelectorAll('temba-contact-field') || []
174
+ ) as ContactFieldEditor[];
175
+ const editing = editors.filter(
176
+ (editor) => editor.dirty && editor.key in previous.fields
177
+ );
178
+
179
+ if (editing.length === 0) {
180
+ return contact;
181
+ }
182
+
183
+ const fields = { ...contact.fields };
184
+ editing.forEach((editor) => {
185
+ fields[editor.key] = previous.fields[editor.key];
186
+ });
187
+ return { ...contact, fields };
188
+ }
189
+
157
190
  public willUpdate(changed: PropertyValues): void {
158
191
  super.willUpdate(changed);
159
192
  if (
@@ -6,6 +6,10 @@ import { ContactNote, CustomEventType } from '../interfaces';
6
6
  import { designTokens } from '../styles/designTokens';
7
7
 
8
8
  export class ContactNotepad extends ContactStoreElement {
9
+ // notes have no contact events - an empty interest list still receives
10
+ // eventless deliveries (initial values, refetches and page-local edits)
11
+ protected watchTypes: string[] = [];
12
+
9
13
  @property({ type: Object, attribute: false })
10
14
  note: ContactNote;
11
15
 
@@ -116,6 +120,10 @@ export class ContactNotepad extends ContactStoreElement {
116
120
  private resizer: ResizeObserver;
117
121
  private lastWidth = 0;
118
122
 
123
+ // the contact our current note was taken off - an edit only belongs to the
124
+ // contact it was typed against, so a switch to another one re-derives
125
+ private noteContact: string = null;
126
+
119
127
  public connectedCallback(): void {
120
128
  super.connectedCallback();
121
129
  // text rewraps when our width changes (browser resize, layout mode
@@ -163,26 +171,53 @@ export class ContactNotepad extends ContactStoreElement {
163
171
  '.notepad'
164
172
  ) as HTMLInputElement;
165
173
  const note = notepad.value;
174
+ // our own write is the one delivery the dirty guard below holds out for,
175
+ // so take the note from it once it lands
166
176
  this.postChanges({ note }).then(() => {
167
- this.markClean();
177
+ this.syncNote();
168
178
  });
169
179
  }
170
180
 
181
+ // takes the newest note off the contact, copied so editing it never writes
182
+ // into the contact data we share with everything else
183
+ private syncNote() {
184
+ this.noteContact = this.data?.uuid || null;
185
+ this.note =
186
+ this.data?.notes?.length > 0
187
+ ? { ...this.data.notes[this.data.notes.length - 1] }
188
+ : null;
189
+ this.fireCustomEvent(CustomEventType.DetailsChanged, {
190
+ count: this.note && this.note.text.length > 0 ? 1 : 0,
191
+ dirty: false
192
+ });
193
+ this.markClean();
194
+
195
+ // lit dirty checks the .value binding against the last string it wrote,
196
+ // not what's actually in the textarea, so re-deriving an unchanged note
197
+ // wouldn't take the user's typing back off - write the note through
198
+ const notepad = this.shadowRoot?.querySelector(
199
+ '.notepad'
200
+ ) as HTMLTextAreaElement;
201
+ if (notepad) {
202
+ notepad.value = this.note ? this.note.text : '';
203
+ }
204
+ }
205
+
171
206
  protected updated(
172
207
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
173
208
  ): void {
174
209
  super.updated(changes);
175
210
 
176
- if (changes.has('data')) {
177
- this.note =
178
- this.data?.notes?.length > 0
179
- ? { ...this.data.notes[this.data.notes.length - 1] }
180
- : null;
181
- this.fireCustomEvent(CustomEventType.DetailsChanged, {
182
- count: this.note && this.note.text.length > 0 ? 1 : 0,
183
- dirty: false
184
- });
185
- this.markClean();
211
+ // the central watcher re-delivers the contact on any activity, not just
212
+ // note changes, so a delivery mid-edit would otherwise drop what the user
213
+ // has typed - hold the note until the edit is saved or abandoned. Only
214
+ // for the contact it was typed against though: on a switch the edit goes
215
+ // with the contact we left, so the new one's note lands right away
216
+ if (
217
+ changes.has('data') &&
218
+ (!this.dirty || (this.data?.uuid || null) !== this.noteContact)
219
+ ) {
220
+ this.syncNote();
186
221
  }
187
222
 
188
223
  if (changes.has('note') || changes.has('data')) {
@@ -1,7 +1,14 @@
1
1
  import { PropertyValues } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
- import { Contact, Group, URN } from '../interfaces';
3
+ import { Contact, CustomEventType, Group, URN } from '../interfaces';
4
4
  import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
5
+ import {
6
+ CONTACT_STATE_TYPES,
7
+ refreshContact,
8
+ updateContact,
9
+ watchContact
10
+ } from './ContactWatch';
11
+ import { RealtimeSubscription } from './Realtime';
5
12
 
6
13
  /**
7
14
  * Returns the URN that will be used to message the given contact — URNs are
@@ -18,6 +25,15 @@ export class ContactStoreElement extends EndpointMonitorElement {
18
25
  @property({ type: Object, attribute: false })
19
26
  data: Contact;
20
27
 
28
+ // the contact events this component registers interest in with the central
29
+ // watcher - subclasses narrow this to what they actually render. An empty
30
+ // list still receives eventless deliveries (initial values and refetches);
31
+ // null opts out of watching entirely
32
+ protected watchTypes: string[] = CONTACT_STATE_TYPES;
33
+
34
+ private watch: RealtimeSubscription = null;
35
+ private watchedContact: string = null;
36
+
21
37
  // Resolve each URN against a channel while retaining the user's priority
22
38
  // order. Consumers can select the first channel-backed URN for messaging.
23
39
  @property({ type: String })
@@ -70,6 +86,35 @@ export class ContactStoreElement extends EndpointMonitorElement {
70
86
  // make sure contact data is properly prepped
71
87
  this.data = this.prepareData([contact]);
72
88
  this.store.updateCache(`${this.endpoint}${contactId}`, this.data);
89
+
90
+ // sync every watcher on the page with the change immediately, without
91
+ // waiting for it to echo back over the socket
92
+ if (this.data) {
93
+ updateContact(this.data.uuid, this.data);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Refetches the contact. When watched we also go through the central
99
+ * watcher so every watcher on the page gets the fresh contact, not just us,
100
+ * while the store fetch keeps the cache fresh for the components that read
101
+ * it without watching. Two fetches for what is a rare manual operation.
102
+ */
103
+ public refresh(): void {
104
+ if (this.watchedContact) {
105
+ refreshContact(this.watchedContact);
106
+ }
107
+ super.refresh();
108
+ }
109
+
110
+ public connectedCallback(): void {
111
+ super.connectedCallback();
112
+ this.syncWatch();
113
+ }
114
+
115
+ public disconnectedCallback(): void {
116
+ super.disconnectedCallback();
117
+ this.syncWatch();
73
118
  }
74
119
 
75
120
  public willUpdate(changed: PropertyValues): void {
@@ -81,7 +126,64 @@ export class ContactStoreElement extends EndpointMonitorElement {
81
126
  } else {
82
127
  this.url = null;
83
128
  }
129
+ this.syncWatch();
84
130
  }
85
131
  super.willUpdate(changed);
86
132
  }
133
+
134
+ // keeps our registration with the central watcher in sync with the
135
+ // contact we're pointed at
136
+ private syncWatch() {
137
+ const target =
138
+ (this.isConnected && this.watchTypes && this.contact) || null;
139
+ if (target === this.watchedContact) {
140
+ return;
141
+ }
142
+
143
+ if (this.watch) {
144
+ this.watch.unsubscribe();
145
+ this.watch = null;
146
+ }
147
+ this.watchedContact = target;
148
+
149
+ if (target) {
150
+ this.watch = watchContact(target, this.watchTypes, (event, contact) =>
151
+ this.handleWatchedContact(event, contact)
152
+ );
153
+ }
154
+ }
155
+
156
+ /**
157
+ * A delivery from the central watcher - an event we registered interest in
158
+ * or an eventless delivery carrying initial or refetched values. Either
159
+ * way the contact is current, so it simply becomes our data.
160
+ */
161
+ protected handleWatchedContact(event: any, contact: Contact) {
162
+ if (contact && contact.uuid === this.watchedContact) {
163
+ const previous = this.data;
164
+ // fresh identity so change detection sees every delivery, copying the
165
+ // parts prepareData mutates so we never write into the registry's
166
+ // snapshot
167
+ const copy = {
168
+ ...contact,
169
+ groups: (contact.groups || []).map((group) => ({ ...group })),
170
+ fields: { ...contact.fields }
171
+ };
172
+ this.data = this.prepareData(this.mergeWatchedContact(copy, previous));
173
+ this.fireCustomEvent(CustomEventType.Refreshed, {
174
+ data: this.data,
175
+ previous
176
+ });
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Lets subclasses keep values they have a write in flight for. A delivery
182
+ * carries server state that predates our own unsaved edits, so anything
183
+ * mid-save would otherwise flip back to its old value until the write
184
+ * lands. Returns the contact to become our data.
185
+ */
186
+ protected mergeWatchedContact(contact: Contact, _previous: Contact): Contact {
187
+ return contact;
188
+ }
87
189
  }