@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.
- package/CHANGELOG.md +19 -0
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +1329 -1378
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +2 -2
- package/src/display/ContactName.ts +123 -1
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/form/Compose.ts +37 -28
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +3 -1
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +351 -8
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/ContactChat.ts +13 -1
- package/src/live/ContactDetails.ts +32 -1
- package/src/live/ContactFields.ts +34 -1
- package/src/live/ContactNotepad.ts +46 -11
- package/src/live/ContactStoreElement.ts +103 -1
- package/src/live/ContactWatch.ts +415 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/simulator/Simulator.ts +1 -0
- package/temba-modules.ts +0 -4
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
- package/src/live/ContactBadges.ts +0 -207
- package/src/live/ContactNameFetch.ts +0 -35
package/src/list/TicketList.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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.
|
|
53
|
-
?
|
|
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
|
-
|
|
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
|
-
${
|
|
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.
|
|
105
|
-
contact.ticket.last_activity_on}
|
|
174
|
+
value=${contact.ticket.last_activity_on}
|
|
106
175
|
display="duration"
|
|
107
176
|
></temba-date>
|
|
108
177
|
</div>
|
package/src/live/ContactChat.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
this.
|
|
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
|
}
|