@nyaruka/temba-components 0.168.0 → 0.169.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 +12 -0
- package/dist/temba-components.js +1099 -1242
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/ContactName.ts +123 -1
- package/src/list/ContactList.ts +2 -0
- package/src/list/ContentList.ts +8 -4
- package/src/list/MsgList.ts +6 -2
- package/src/live/ContactChat.ts +6 -0
- 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/temba-modules.ts +0 -4
- package/src/live/ContactBadges.ts +0 -207
- package/src/live/ContactNameFetch.ts +0 -35
package/package.json
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import { css, html, TemplateResult } from 'lit';
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
|
+
import { Contact, CustomEventType } from '../interfaces';
|
|
5
|
+
import { Events } from '../events/eventRenderers';
|
|
6
|
+
import { getDestinationURN } from '../live/ContactStoreElement';
|
|
7
|
+
import { watchContact } from '../live/ContactWatch';
|
|
8
|
+
import { RealtimeSubscription } from '../live/Realtime';
|
|
4
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A contact's name, optionally with the URN that will be used to message
|
|
12
|
+
* them. Given a contact uuid it manages itself: it registers interest in name
|
|
13
|
+
* events with the central contact watcher, which delivers the initial value
|
|
14
|
+
* and any live changes through the same handler - the component never fetches
|
|
15
|
+
* anything. Alternatively the name and urn can be set explicitly for static
|
|
16
|
+
* rendering (e.g. rows of a list that arrived in one fetch).
|
|
17
|
+
*/
|
|
5
18
|
export class ContactName extends RapidElement {
|
|
6
19
|
@property({ type: String })
|
|
7
20
|
name: string;
|
|
@@ -9,9 +22,25 @@ export class ContactName extends RapidElement {
|
|
|
9
22
|
@property({ type: String })
|
|
10
23
|
urn: string;
|
|
11
24
|
|
|
25
|
+
// when set, name and urn are kept live by the central contact watcher
|
|
26
|
+
@property({ type: String })
|
|
27
|
+
contact: string;
|
|
28
|
+
|
|
12
29
|
@property({ type: Number, attribute: 'icon-size' })
|
|
13
30
|
size = 20;
|
|
14
31
|
|
|
32
|
+
private watch: RealtimeSubscription = null;
|
|
33
|
+
private watchedContact: string = null;
|
|
34
|
+
|
|
35
|
+
// the contact the name and urn on screen came from - only what we put there
|
|
36
|
+
// is ours to clear, and only when we're pointed somewhere else
|
|
37
|
+
private displayedContact: string = null;
|
|
38
|
+
|
|
39
|
+
// the contact behind a refresh notification we've already queued - a name
|
|
40
|
+
// and a urn change arrive as separate deliveries, as does a burst of live
|
|
41
|
+
// events, so the notification is coalesced into a microtask
|
|
42
|
+
private pendingRefresh: Contact = null;
|
|
43
|
+
|
|
15
44
|
static get styles() {
|
|
16
45
|
return css`
|
|
17
46
|
:host {
|
|
@@ -34,6 +63,99 @@ export class ContactName extends RapidElement {
|
|
|
34
63
|
}
|
|
35
64
|
`;
|
|
36
65
|
}
|
|
66
|
+
|
|
67
|
+
public connectedCallback(): void {
|
|
68
|
+
super.connectedCallback();
|
|
69
|
+
this.syncWatch();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public disconnectedCallback(): void {
|
|
73
|
+
super.disconnectedCallback();
|
|
74
|
+
this.syncWatch();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public willUpdate(changed: PropertyValues): void {
|
|
78
|
+
super.willUpdate(changed);
|
|
79
|
+
if (changed.has('contact')) {
|
|
80
|
+
this.syncWatch();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keeps our watch in sync with the contact we're pointed at, clearing the
|
|
86
|
+
* previous contact's values when we switch contacts on the page. Explicitly
|
|
87
|
+
* set name/urn never pass through here since they involve no watch.
|
|
88
|
+
*/
|
|
89
|
+
private syncWatch() {
|
|
90
|
+
const target = (this.isConnected && this.contact) || null;
|
|
91
|
+
if (target === this.watchedContact) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.watch) {
|
|
96
|
+
this.watch.unsubscribe();
|
|
97
|
+
this.watch = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// only values we put there are ours to clear - a name and urn supplied
|
|
101
|
+
// alongside the contact render until the watcher has something better.
|
|
102
|
+
// Leaving the DOM isn't a switch, and blanking there would flash empty
|
|
103
|
+
// and refetch when we're only being moved - but a swap made while we were
|
|
104
|
+
// detached is, so compare against the contact on screen rather than the
|
|
105
|
+
// one we were last watching
|
|
106
|
+
if (
|
|
107
|
+
this.isConnected &&
|
|
108
|
+
this.displayedContact &&
|
|
109
|
+
this.displayedContact !== target
|
|
110
|
+
) {
|
|
111
|
+
this.name = null;
|
|
112
|
+
this.urn = null;
|
|
113
|
+
this.displayedContact = null;
|
|
114
|
+
}
|
|
115
|
+
this.watchedContact = target;
|
|
116
|
+
|
|
117
|
+
if (target) {
|
|
118
|
+
this.watch = watchContact(
|
|
119
|
+
target,
|
|
120
|
+
[Events.CONTACT_NAME_CHANGED, Events.CONTACT_URNS_CHANGED],
|
|
121
|
+
(event, contact) => this.handleContactEvent(event, contact)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private handleContactEvent(event: any, contact: Contact) {
|
|
127
|
+
if (contact) {
|
|
128
|
+
// the watcher keeps the contact current, so it's authoritative whether
|
|
129
|
+
// this delivery is an event or an initial value. Anon workspaces have
|
|
130
|
+
// no names, show the contact's ref instead
|
|
131
|
+
this.name = contact.name || contact.ref || null;
|
|
132
|
+
|
|
133
|
+
// show the URN that will be used when messaging the contact, falling
|
|
134
|
+
// back to their highest priority URN if none are sendable
|
|
135
|
+
const urn = getDestinationURN(contact) || (contact.urns || [])[0] || null;
|
|
136
|
+
this.urn = urn ? `${urn.scheme}:${urn.display || urn.path}` : null;
|
|
137
|
+
this.displayedContact = this.watchedContact;
|
|
138
|
+
this.fireRefreshed(contact);
|
|
139
|
+
} else if (event && event.type === Events.CONTACT_NAME_CHANGED) {
|
|
140
|
+
// a live event can outrun the initial fetch - show the name we have
|
|
141
|
+
this.name = event.name || null;
|
|
142
|
+
this.displayedContact = this.watchedContact;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fireRefreshed(contact: Contact) {
|
|
147
|
+
if (!this.pendingRefresh) {
|
|
148
|
+
Promise.resolve().then(() => {
|
|
149
|
+
const refreshed = this.pendingRefresh;
|
|
150
|
+
this.pendingRefresh = null;
|
|
151
|
+
if (refreshed) {
|
|
152
|
+
this.fireCustomEvent(CustomEventType.Refreshed, { data: refreshed });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
this.pendingRefresh = contact;
|
|
157
|
+
}
|
|
158
|
+
|
|
37
159
|
public render(): TemplateResult {
|
|
38
160
|
const urn = this.urn
|
|
39
161
|
? html`<temba-urn size=${this.size} urn=${this.urn}></temba-urn>`
|
package/src/list/ContactList.ts
CHANGED
|
@@ -79,6 +79,8 @@ export class ContactList extends ContentList<Contact> {
|
|
|
79
79
|
labelsEndpoint: '/api/v2/groups.json?manual_only=1',
|
|
80
80
|
labelsKey: 'groups'
|
|
81
81
|
},
|
|
82
|
+
// Archive trails the reversible actions; Delete, when offered,
|
|
83
|
+
// always goes last.
|
|
82
84
|
{ key: 'archive', label: 'Archive', icon: Icon.archive },
|
|
83
85
|
{ key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
|
|
84
86
|
];
|
package/src/list/ContentList.ts
CHANGED
|
@@ -214,7 +214,9 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
214
214
|
the select-all checkbox — rather than replacing the page header.
|
|
215
215
|
Positioned in the table-frame (not the scrolling table) so it
|
|
216
216
|
stays put horizontally, with an opaque header-tint background to
|
|
217
|
-
hide the labels underneath, above
|
|
217
|
+
hide the labels underneath, above every header layer — pinned
|
|
218
|
+
header cells reach z 5 (see tr.header th.pinned), so the bar
|
|
219
|
+
sits at 6 to cover them too. */
|
|
218
220
|
.bulk-bar {
|
|
219
221
|
position: absolute;
|
|
220
222
|
top: 0;
|
|
@@ -225,7 +227,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
225
227
|
left: var(--cl-firstcol-left, 44px);
|
|
226
228
|
right: var(--cl-scrollbar-w, 0px);
|
|
227
229
|
height: var(--cl-header-height, 36px);
|
|
228
|
-
z-index:
|
|
230
|
+
z-index: 6;
|
|
229
231
|
display: flex;
|
|
230
232
|
align-items: center;
|
|
231
233
|
gap: 4px;
|
|
@@ -567,8 +569,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
567
569
|
|
|
568
570
|
/* The header row sticks to the top of the scroll frame so the
|
|
569
571
|
column labels stay put while the rows scroll under them.
|
|
570
|
-
z-index 2 lifts it above the body; a pinned header cell
|
|
571
|
-
|
|
572
|
+
z-index 2 lifts it above the body; a pinned header cell needs
|
|
573
|
+
more to also clear the body's pinned column (z-index 1) and the
|
|
574
|
+
resize-handle layers (z 4) — but must stay under the bulk-action
|
|
575
|
+
bar (z 6), which overlays the whole header row on selection. */
|
|
572
576
|
tr.header th {
|
|
573
577
|
height: 36px;
|
|
574
578
|
vertical-align: middle;
|
package/src/list/MsgList.ts
CHANGED
|
@@ -118,11 +118,13 @@ export class MsgList extends ContentList<Msg> {
|
|
|
118
118
|
resizable: true
|
|
119
119
|
},
|
|
120
120
|
{ key: 'text', label: 'Message', grow: true },
|
|
121
|
+
// Sent is deliberately not resizable: it's the last column, so its
|
|
122
|
+
// only possible handle would sit on the table's outer edge (Message
|
|
123
|
+
// is the grow column and owns the boundary between them).
|
|
121
124
|
{
|
|
122
125
|
key: 'created_on',
|
|
123
126
|
label: 'Sent',
|
|
124
|
-
align: 'right'
|
|
125
|
-
resizable: true
|
|
127
|
+
align: 'right'
|
|
126
128
|
}
|
|
127
129
|
];
|
|
128
130
|
this.bulkActions = [
|
|
@@ -132,6 +134,8 @@ export class MsgList extends ContentList<Msg> {
|
|
|
132
134
|
icon: Icon.label,
|
|
133
135
|
labelsEndpoint: '/api/v2/labels.json'
|
|
134
136
|
},
|
|
137
|
+
// Archive trails the reversible actions; Delete, when offered,
|
|
138
|
+
// always goes last.
|
|
135
139
|
{ key: 'archive', label: 'Archive', icon: Icon.archive },
|
|
136
140
|
{ key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
|
|
137
141
|
];
|
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}
|
|
@@ -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
|
}
|