@nyaruka/temba-components 0.165.0 → 0.167.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 +13 -0
- package/dist/temba-components.js +674 -625
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +232 -18
- package/src/interfaces.ts +21 -0
- package/src/list/ContactList.ts +38 -7
- package/src/list/NotificationList.ts +131 -21
- package/src/list/TembaList.ts +11 -5
- package/src/list/TembaMenu.ts +42 -8
- package/src/live/ContactChat.ts +185 -19
- package/src/live/Realtime.ts +133 -0
- package/src/live/SocketService.ts +24 -0
- package/src/store/Store.ts +12 -0
- package/src/utils.ts +4 -0
package/package.json
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -11,6 +11,10 @@ const BATCH_TIME_WINDOW = 60 * 60 * 1000;
|
|
|
11
11
|
const SCROLL_FETCH_BUFFER = 200; // pixels from top
|
|
12
12
|
const MIN_FETCH_TIME = 250;
|
|
13
13
|
|
|
14
|
+
// how long a typing indicator lives without a fresh pulse - stop events can
|
|
15
|
+
// be missed (they're excluded from history recovery) so indicators decay
|
|
16
|
+
const TYPING_TIMEOUT = 10 * 1000;
|
|
17
|
+
|
|
14
18
|
const getUnsendableReasonMessage = (reason: string): string => {
|
|
15
19
|
switch (reason) {
|
|
16
20
|
case 'no_route':
|
|
@@ -80,6 +84,7 @@ export interface Msg {
|
|
|
80
84
|
urn: string;
|
|
81
85
|
direction: string;
|
|
82
86
|
type: string;
|
|
87
|
+
external_id?: string;
|
|
83
88
|
attachments: string[];
|
|
84
89
|
unsendable_reason?:
|
|
85
90
|
| 'no_route'
|
|
@@ -114,6 +119,28 @@ export interface MsgEvent extends ContactEvent {
|
|
|
114
119
|
_logs_url?: string;
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
export interface TypingEvent extends ContactEvent {
|
|
123
|
+
direction?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// typing indicators group and side with real messages: a user typing
|
|
127
|
+
// (outgoing) behaves like a reply, a contact typing (incoming) like a
|
|
128
|
+
// received message
|
|
129
|
+
const effectiveType = (event: ContactEvent): string => {
|
|
130
|
+
if (event.type === 'typing_started') {
|
|
131
|
+
return (event as TypingEvent).direction === 'incoming'
|
|
132
|
+
? 'msg_received'
|
|
133
|
+
: 'msg_created';
|
|
134
|
+
}
|
|
135
|
+
return event.type;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// identifies who a typing indicator belongs to - the user when stamped,
|
|
139
|
+
// otherwise the contact
|
|
140
|
+
const getTypingKey = (event: TypingEvent): string => {
|
|
141
|
+
return event._user?.uuid || 'contact';
|
|
142
|
+
};
|
|
143
|
+
|
|
117
144
|
// whether a message has nothing to show in its bubble (and isn't deleted)
|
|
118
145
|
const isEmptyMsg = (event: MsgEvent): boolean =>
|
|
119
146
|
!event._deleted &&
|
|
@@ -371,6 +398,43 @@ export class Chat extends RapidElement {
|
|
|
371
398
|
color: #aaa;
|
|
372
399
|
}
|
|
373
400
|
|
|
401
|
+
.typing-dots {
|
|
402
|
+
display: flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
gap: 4px;
|
|
405
|
+
padding: 4px 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.typing-dots span {
|
|
409
|
+
width: 7px;
|
|
410
|
+
height: 7px;
|
|
411
|
+
border-radius: 50%;
|
|
412
|
+
background: currentColor;
|
|
413
|
+
opacity: 0.4;
|
|
414
|
+
animation: typing-bounce 1.2s ease-in-out infinite;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.typing-dots span:nth-child(2) {
|
|
418
|
+
animation-delay: 0.15s;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.typing-dots span:nth-child(3) {
|
|
422
|
+
animation-delay: 0.3s;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@keyframes typing-bounce {
|
|
426
|
+
0%,
|
|
427
|
+
60%,
|
|
428
|
+
100% {
|
|
429
|
+
transform: translateY(0);
|
|
430
|
+
opacity: 0.4;
|
|
431
|
+
}
|
|
432
|
+
30% {
|
|
433
|
+
transform: translateY(-3px);
|
|
434
|
+
opacity: 1;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
374
438
|
.message-text {
|
|
375
439
|
white-space: pre-wrap;
|
|
376
440
|
margin-bottom: 0;
|
|
@@ -849,10 +913,24 @@ export class Chat extends RapidElement {
|
|
|
849
913
|
private msgMap = new Map<string, ContactEvent>();
|
|
850
914
|
private metadataCache = new Map<string, ContactEvent>();
|
|
851
915
|
|
|
916
|
+
// ephemeral typing indicators by author key - they live in msgMap and
|
|
917
|
+
// messageGroups like real messages (so they group and render normally)
|
|
918
|
+
// but are added and removed through setTyping / clearTyping
|
|
919
|
+
private typingEvents = new Map<string, TypingEvent>();
|
|
920
|
+
private typingTimeouts = new Map<string, number>();
|
|
921
|
+
|
|
852
922
|
// bumped on reset so deferred addMessages work from before a reset is
|
|
853
923
|
// discarded rather than re-merged into the fresh view
|
|
854
924
|
private resetGeneration = 0;
|
|
855
925
|
|
|
926
|
+
public disconnectedCallback(): void {
|
|
927
|
+
super.disconnectedCallback();
|
|
928
|
+
// clear pending typing decay timers so no callback fires on a detached
|
|
929
|
+
// element (typingEvents/msgMap are left intact for a possible reconnect)
|
|
930
|
+
this.typingTimeouts.forEach((timeout) => window.clearTimeout(timeout));
|
|
931
|
+
this.typingTimeouts.clear();
|
|
932
|
+
}
|
|
933
|
+
|
|
856
934
|
public firstUpdated(
|
|
857
935
|
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
858
936
|
): void {
|
|
@@ -911,9 +989,21 @@ export class Chat extends RapidElement {
|
|
|
911
989
|
const isScrolledAway =
|
|
912
990
|
scrollableHeight > 0 && Math.abs(ele.scrollTop) > 50;
|
|
913
991
|
|
|
992
|
+
// a real message from someone typing replaces their indicator, and
|
|
993
|
+
// any remaining indicators re-float below the appended messages
|
|
994
|
+
if (append) {
|
|
995
|
+
this.clearTypingForAuthors(
|
|
996
|
+
newMessages.map((uuid) => this.msgMap.get(uuid))
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
914
1000
|
const grouped = this.groupMessages(newMessages);
|
|
915
1001
|
this.insertGroups(grouped, append);
|
|
916
1002
|
|
|
1003
|
+
if (append) {
|
|
1004
|
+
this.refloatTyping();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
917
1007
|
// show notification if new messages are appended and user is scrolled away from bottom
|
|
918
1008
|
// but not during search (searchHighlight is set)
|
|
919
1009
|
if (
|
|
@@ -986,6 +1076,115 @@ export class Chat extends RapidElement {
|
|
|
986
1076
|
return isNew;
|
|
987
1077
|
}
|
|
988
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Shows a typing indicator for the event's author, appended to the
|
|
1081
|
+
* newest messages so it groups with them. Repeat pulses from the same
|
|
1082
|
+
* author just refresh the decay timer; without fresh pulses or an
|
|
1083
|
+
* explicit clearTyping the indicator decays on its own.
|
|
1084
|
+
*/
|
|
1085
|
+
public setTyping(event: TypingEvent) {
|
|
1086
|
+
const key = getTypingKey(event);
|
|
1087
|
+
const shown = this.typingEvents.get(key);
|
|
1088
|
+
if (shown) {
|
|
1089
|
+
// keep the indicator's timestamp fresh so a long-lived one doesn't
|
|
1090
|
+
// drift across the grouping time window and split into its own group
|
|
1091
|
+
shown.created_on = event.created_on;
|
|
1092
|
+
} else {
|
|
1093
|
+
this.typingEvents.set(key, event);
|
|
1094
|
+
this.addMessage(event);
|
|
1095
|
+
this.insertGroups(this.groupMessages([event.uuid]), true);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const existing = this.typingTimeouts.get(key);
|
|
1099
|
+
if (existing) {
|
|
1100
|
+
window.clearTimeout(existing);
|
|
1101
|
+
}
|
|
1102
|
+
this.typingTimeouts.set(
|
|
1103
|
+
key,
|
|
1104
|
+
window.setTimeout(() => this.removeTyping(key), TYPING_TIMEOUT)
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Clears the typing indicator for the event's author, or all indicators
|
|
1110
|
+
* when no event is given.
|
|
1111
|
+
*/
|
|
1112
|
+
public clearTyping(event?: TypingEvent) {
|
|
1113
|
+
if (event) {
|
|
1114
|
+
this.removeTyping(getTypingKey(event));
|
|
1115
|
+
} else {
|
|
1116
|
+
for (const key of [...this.typingEvents.keys()]) {
|
|
1117
|
+
this.removeTyping(key);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private removeTyping(key: string) {
|
|
1123
|
+
const timeout = this.typingTimeouts.get(key);
|
|
1124
|
+
if (timeout) {
|
|
1125
|
+
window.clearTimeout(timeout);
|
|
1126
|
+
this.typingTimeouts.delete(key);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const event = this.typingEvents.get(key);
|
|
1130
|
+
if (event) {
|
|
1131
|
+
this.typingEvents.delete(key);
|
|
1132
|
+
this.removeEvent(event.uuid);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private removeEvent(uuid: string) {
|
|
1137
|
+
this.msgMap.delete(uuid);
|
|
1138
|
+
for (let i = 0; i < this.messageGroups.length; i++) {
|
|
1139
|
+
const group = this.messageGroups[i];
|
|
1140
|
+
const idx = group.messages.indexOf(uuid);
|
|
1141
|
+
if (idx >= 0) {
|
|
1142
|
+
group.messages.splice(idx, 1);
|
|
1143
|
+
if (group.messages.length === 0) {
|
|
1144
|
+
this.messageGroups.splice(i, 1);
|
|
1145
|
+
}
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
this.requestUpdate('messageGroups');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// an author's real message replaces their typing indicator
|
|
1153
|
+
private clearTypingForAuthors(events: ContactEvent[]) {
|
|
1154
|
+
for (const event of events) {
|
|
1155
|
+
if (event.type === 'typing_started') {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const key = event._user?.uuid
|
|
1159
|
+
? event._user.uuid
|
|
1160
|
+
: event.type === 'msg_received'
|
|
1161
|
+
? 'contact'
|
|
1162
|
+
: null;
|
|
1163
|
+
if (key && this.typingEvents.has(key)) {
|
|
1164
|
+
this.removeTyping(key);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// re-appends active typing indicators so they always sit below the
|
|
1170
|
+
// newest messages
|
|
1171
|
+
private refloatTyping() {
|
|
1172
|
+
if (this.typingEvents.size === 0) {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const events = [...this.typingEvents.values()];
|
|
1176
|
+
for (const event of events) {
|
|
1177
|
+
this.removeEvent(event.uuid);
|
|
1178
|
+
}
|
|
1179
|
+
for (const event of events) {
|
|
1180
|
+
this.addMessage(event);
|
|
1181
|
+
}
|
|
1182
|
+
this.insertGroups(
|
|
1183
|
+
this.groupMessages(events.map((event) => event.uuid)),
|
|
1184
|
+
true
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
989
1188
|
public messageExists(msg: ContactEvent): boolean {
|
|
990
1189
|
return this.msgMap.has(msg.uuid);
|
|
991
1190
|
}
|
|
@@ -1002,18 +1201,19 @@ export class Chat extends RapidElement {
|
|
|
1002
1201
|
// for type equivalence, treat all non-message types as the same.
|
|
1003
1202
|
// Notes are treated like messages so they group with other notes
|
|
1004
1203
|
// only when type + author match (and split from non-note events).
|
|
1204
|
+
const type1 = effectiveType(msg1);
|
|
1205
|
+
const type2 = effectiveType(msg2);
|
|
1005
1206
|
const isMsg1 =
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1207
|
+
type1 === 'msg_created' ||
|
|
1208
|
+
type1 === 'msg_received' ||
|
|
1209
|
+
type1 === 'ivr_created' ||
|
|
1210
|
+
type1 === 'ticket_note_added';
|
|
1010
1211
|
const isMsg2 =
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const typeMatch =
|
|
1016
|
-
isMsg1 && isMsg2 ? msg1.type === msg2.type : isMsg1 === isMsg2;
|
|
1212
|
+
type2 === 'msg_created' ||
|
|
1213
|
+
type2 === 'msg_received' ||
|
|
1214
|
+
type2 === 'ivr_created' ||
|
|
1215
|
+
type2 === 'ticket_note_added';
|
|
1216
|
+
const typeMatch = isMsg1 && isMsg2 ? type1 === type2 : isMsg1 === isMsg2;
|
|
1017
1217
|
|
|
1018
1218
|
// check time first - if BATCH_TIME_WINDOW has passed since last time_elapsed reason
|
|
1019
1219
|
// compare the current message (msg1) against the last break point to detect
|
|
@@ -1233,17 +1433,18 @@ export class Chat extends RapidElement {
|
|
|
1233
1433
|
const mostRecentId = msgIds[msgIds.length - 1];
|
|
1234
1434
|
const currentMsg = this.msgMap.get(mostRecentId);
|
|
1235
1435
|
|
|
1436
|
+
const currentType = effectiveType(currentMsg);
|
|
1236
1437
|
const incoming = this.agent
|
|
1237
|
-
?
|
|
1238
|
-
:
|
|
1438
|
+
? currentType !== 'msg_received'
|
|
1439
|
+
: currentType === 'msg_received';
|
|
1239
1440
|
|
|
1240
1441
|
const name = currentMsg._user?.name;
|
|
1241
1442
|
|
|
1242
1443
|
const isMessageType =
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1444
|
+
currentType === 'msg_received' ||
|
|
1445
|
+
currentType === 'msg_created' ||
|
|
1446
|
+
currentType === 'ivr_created' ||
|
|
1447
|
+
currentType === 'ticket_note_added';
|
|
1247
1448
|
const showAvatar =
|
|
1248
1449
|
this.avatars && ((isMessageType && this.agent) || !incoming);
|
|
1249
1450
|
|
|
@@ -1255,7 +1456,7 @@ export class Chat extends RapidElement {
|
|
|
1255
1456
|
// messages carry no `_user`, so first_name/last_name aren't available and
|
|
1256
1457
|
// getFullName falls back to `name`); the fallback only applies when there
|
|
1257
1458
|
// is no `_user` on the event.
|
|
1258
|
-
const fromContact =
|
|
1459
|
+
const fromContact = currentType === 'msg_received' && !currentMsg._user;
|
|
1259
1460
|
const avatarName = currentMsg._user
|
|
1260
1461
|
? currentMsg._user.name
|
|
1261
1462
|
: fromContact
|
|
@@ -1340,10 +1541,11 @@ export class Chat extends RapidElement {
|
|
|
1340
1541
|
const latestClass = index === msgIds.length - 1 ? 'latest' : '';
|
|
1341
1542
|
const eventClass = msg._rendered ? 'is-event' : '';
|
|
1342
1543
|
const noteClass = msg.type === 'ticket_note_added' ? 'note' : '';
|
|
1544
|
+
const typingClass = msg.type === 'typing_started' ? 'typing' : '';
|
|
1343
1545
|
const matchClass =
|
|
1344
1546
|
this.highlightMessageUuid === msg.uuid ? 'search-match' : '';
|
|
1345
1547
|
return html`<div
|
|
1346
|
-
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${matchClass}"
|
|
1548
|
+
class="row message ${statusClass} ${unsendableClass} ${deletedClass} ${emptyClass} ${latestClass} ${eventClass} ${noteClass} ${typingClass} ${matchClass}"
|
|
1347
1549
|
data-uuid=${msg.uuid || nothing}
|
|
1348
1550
|
>
|
|
1349
1551
|
${this.renderMessage(
|
|
@@ -1412,6 +1614,15 @@ export class Chat extends RapidElement {
|
|
|
1412
1614
|
return this.renderNote(event as TicketEvent, name, isLast);
|
|
1413
1615
|
}
|
|
1414
1616
|
|
|
1617
|
+
if (event.type === 'typing_started') {
|
|
1618
|
+
return html`<div class="bubble-wrap">
|
|
1619
|
+
<div class="bubble">
|
|
1620
|
+
${name ? html`<div class="name">${name}</div>` : null}
|
|
1621
|
+
<div class="typing-dots"><span></span><span></span><span></span></div>
|
|
1622
|
+
</div>
|
|
1623
|
+
</div>`;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1415
1626
|
const message = event as MsgEvent;
|
|
1416
1627
|
|
|
1417
1628
|
// safety check: if msg doesn't exist, return nothing
|
|
@@ -1521,6 +1732,9 @@ export class Chat extends RapidElement {
|
|
|
1521
1732
|
|
|
1522
1733
|
public reset() {
|
|
1523
1734
|
this.resetGeneration++;
|
|
1735
|
+
this.typingTimeouts.forEach((timeout) => window.clearTimeout(timeout));
|
|
1736
|
+
this.typingTimeouts.clear();
|
|
1737
|
+
this.typingEvents.clear();
|
|
1524
1738
|
this.msgMap.clear();
|
|
1525
1739
|
this.metadataCache.clear();
|
|
1526
1740
|
this.messageGroups = [];
|
package/src/interfaces.ts
CHANGED
|
@@ -18,6 +18,26 @@ export interface Language {
|
|
|
18
18
|
name: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface Notification {
|
|
22
|
+
created_on: string;
|
|
23
|
+
type: string;
|
|
24
|
+
url: string;
|
|
25
|
+
is_seen: boolean;
|
|
26
|
+
export?: {
|
|
27
|
+
type: string;
|
|
28
|
+
num_records: number;
|
|
29
|
+
};
|
|
30
|
+
import?: {
|
|
31
|
+
type: string;
|
|
32
|
+
num_records: number;
|
|
33
|
+
};
|
|
34
|
+
incident?: {
|
|
35
|
+
type: string;
|
|
36
|
+
started_on: string;
|
|
37
|
+
ended_on?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export interface Attachment {
|
|
22
42
|
uuid: string;
|
|
23
43
|
content_type: string;
|
|
@@ -468,6 +488,7 @@ export enum CustomEventType {
|
|
|
468
488
|
Error = 'temba-error',
|
|
469
489
|
Interrupt = 'temba-interrupt',
|
|
470
490
|
Opened = 'temba-opened',
|
|
491
|
+
NotificationReceived = 'temba-notification',
|
|
471
492
|
TicketUpdated = 'temba-ticket-updated',
|
|
472
493
|
Moved = 'temba-moved',
|
|
473
494
|
DateRangeChanged = 'temba-date-range-changed',
|
package/src/list/ContactList.ts
CHANGED
|
@@ -49,6 +49,12 @@ export class ContactList extends ContentList<Contact> {
|
|
|
49
49
|
@property({ type: String, attribute: 'fields-endpoint' })
|
|
50
50
|
fieldsEndpoint = '/api/v2/fields.json';
|
|
51
51
|
|
|
52
|
+
/** Anonymous workspaces mask URN values, so instead of the URN
|
|
53
|
+
* column the list shows each contact's ref (served as its own
|
|
54
|
+
* anon-only key by the endpoint). */
|
|
55
|
+
@property({ type: Boolean })
|
|
56
|
+
anon = false;
|
|
57
|
+
|
|
52
58
|
@state()
|
|
53
59
|
private featuredFields: any[] = [];
|
|
54
60
|
|
|
@@ -91,6 +97,16 @@ export class ContactList extends ContentList<Contact> {
|
|
|
91
97
|
super.disconnectedCallback();
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
protected willUpdate(changes: PropertyValues): void {
|
|
101
|
+
super.willUpdate(changes);
|
|
102
|
+
// Rebuilding here (rather than in updated()) means a mounted
|
|
103
|
+
// anon attribute is reflected in the first paint instead of
|
|
104
|
+
// flashing the URN header for a frame.
|
|
105
|
+
if (changes.has('anon')) {
|
|
106
|
+
this.columns = this.buildColumns();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
protected updated(changes: PropertyValues): void {
|
|
95
111
|
super.updated(changes);
|
|
96
112
|
if (changes.has('fieldsEndpoint') && this.fieldsEndpoint) {
|
|
@@ -168,12 +184,19 @@ export class ContactList extends ContentList<Contact> {
|
|
|
168
184
|
maxWidth: '260px',
|
|
169
185
|
pinned: true
|
|
170
186
|
},
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
this.anon
|
|
188
|
+
? {
|
|
189
|
+
key: 'ref',
|
|
190
|
+
label: 'Ref',
|
|
191
|
+
minWidth: '120px',
|
|
192
|
+
maxWidth: '190px'
|
|
193
|
+
}
|
|
194
|
+
: {
|
|
195
|
+
key: 'urn',
|
|
196
|
+
label: 'URN',
|
|
197
|
+
minWidth: '120px',
|
|
198
|
+
maxWidth: '190px'
|
|
199
|
+
},
|
|
177
200
|
...fieldColumns,
|
|
178
201
|
{
|
|
179
202
|
key: 'last_seen_on',
|
|
@@ -232,6 +255,10 @@ export class ContactList extends ContentList<Contact> {
|
|
|
232
255
|
return html`<span class="contact-urn"
|
|
233
256
|
>${this.primaryUrn(item) || EMPTY}</span
|
|
234
257
|
>`;
|
|
258
|
+
case 'ref':
|
|
259
|
+
return html`<span class="contact-urn"
|
|
260
|
+
>${(item as any).ref || EMPTY}</span
|
|
261
|
+
>`;
|
|
235
262
|
case 'last_seen_on':
|
|
236
263
|
return item.last_seen_on
|
|
237
264
|
? html`<temba-date
|
|
@@ -281,7 +308,11 @@ export class ContactList extends ContentList<Contact> {
|
|
|
281
308
|
|
|
282
309
|
private primaryUrn(item: Contact): string {
|
|
283
310
|
const i = item as any;
|
|
284
|
-
if (i.urn)
|
|
311
|
+
if (i.urn) {
|
|
312
|
+
// served as {scheme, display} by the list endpoint, but tolerate
|
|
313
|
+
// a plain string (e.g. the public API's urn field)
|
|
314
|
+
return typeof i.urn === 'string' ? i.urn : i.urn.display || '';
|
|
315
|
+
}
|
|
285
316
|
if (Array.isArray(i.urns) && i.urns.length > 0) {
|
|
286
317
|
const u = i.urns[0];
|
|
287
318
|
return typeof u === 'string'
|
|
@@ -1,31 +1,28 @@
|
|
|
1
|
-
import { css, html, TemplateResult } from 'lit';
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
2
|
import { TembaList } from './TembaList';
|
|
3
3
|
import { Options } from '../display/Options';
|
|
4
4
|
import { Icon } from '../Icons';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export?: {
|
|
12
|
-
type: string;
|
|
13
|
-
num_records: number;
|
|
14
|
-
};
|
|
15
|
-
import?: {
|
|
16
|
-
type: string;
|
|
17
|
-
num_records: number;
|
|
18
|
-
};
|
|
19
|
-
incident?: {
|
|
20
|
-
type: string;
|
|
21
|
-
started_on: string;
|
|
22
|
-
ended_on?: string;
|
|
23
|
-
};
|
|
24
|
-
}
|
|
5
|
+
import { CustomEventType, Notification } from '../interfaces';
|
|
6
|
+
import {
|
|
7
|
+
RealtimeSubscription,
|
|
8
|
+
subscribeToNotifications
|
|
9
|
+
} from '../live/Realtime';
|
|
10
|
+
import { deleteRequest } from '../utils';
|
|
25
11
|
|
|
26
12
|
export class NotificationList extends TembaList {
|
|
27
13
|
reverseRefresh = false;
|
|
28
14
|
internalFocusDisabled = true;
|
|
15
|
+
|
|
16
|
+
// fed by socket publications instead of interval polling
|
|
17
|
+
protected pollingEnabled = false;
|
|
18
|
+
|
|
19
|
+
private realtimeSubscription: RealtimeSubscription = null;
|
|
20
|
+
|
|
21
|
+
// publications that arrived before the initial fetch completed - a direct
|
|
22
|
+
// prepend would be clobbered when the fetch lands, so they wait for it
|
|
23
|
+
private pendingPubs: Notification[] = [];
|
|
24
|
+
private fetchedOnce = false;
|
|
25
|
+
private subscribedOnce = false;
|
|
29
26
|
static get styles() {
|
|
30
27
|
return css`
|
|
31
28
|
:host {
|
|
@@ -134,6 +131,119 @@ export class NotificationList extends TembaList {
|
|
|
134
131
|
};
|
|
135
132
|
}
|
|
136
133
|
|
|
134
|
+
public connectedCallback() {
|
|
135
|
+
super.connectedCallback();
|
|
136
|
+
this.realtimeSubscription = subscribeToNotifications(
|
|
137
|
+
(notification) => this.handleNotification(notification),
|
|
138
|
+
() => this.handleSubscribed()
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public disconnectedCallback() {
|
|
143
|
+
super.disconnectedCallback();
|
|
144
|
+
if (this.realtimeSubscription) {
|
|
145
|
+
this.realtimeSubscription.unsubscribe();
|
|
146
|
+
this.realtimeSubscription = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public willUpdate(changed: PropertyValues): void {
|
|
151
|
+
super.willUpdate(changed);
|
|
152
|
+
if (changed.has('loading') && !this.loading) {
|
|
153
|
+
this.fetchedOnce = true;
|
|
154
|
+
const pending = this.pendingPubs;
|
|
155
|
+
this.pendingPubs = [];
|
|
156
|
+
pending.forEach((notification) => this.prependNotification(notification));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private handleNotification(notification: Notification) {
|
|
161
|
+
// ignore redeliveries of notifications we already have so e.g. the
|
|
162
|
+
// menu's unseen badge only lights for genuinely new ones
|
|
163
|
+
if (
|
|
164
|
+
this.items.some((item) => item.url === notification.url) ||
|
|
165
|
+
this.pendingPubs.some((item) => item.url === notification.url)
|
|
166
|
+
) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// announce arrival, even mid-fetch - e.g. the menu's unseen badge
|
|
171
|
+
this.fireCustomEvent(CustomEventType.NotificationReceived, {
|
|
172
|
+
notification
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (this.loading || !this.fetchedOnce) {
|
|
176
|
+
this.pendingPubs.push(notification);
|
|
177
|
+
} else {
|
|
178
|
+
this.prependNotification(notification);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private prependNotification(notification: Notification) {
|
|
183
|
+
// dedupe by url (our valueKey) and prepend
|
|
184
|
+
this.items = [
|
|
185
|
+
notification,
|
|
186
|
+
...this.items.filter((item) => item.url !== notification.url)
|
|
187
|
+
];
|
|
188
|
+
this.mostRecentItem = notification;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fires on every (re)subscribe including after reconnects. The initial
|
|
193
|
+
* endpoint fetch covers the first one regardless of which completes first,
|
|
194
|
+
* so only refetch page one on resubscribes, to catch anything missed
|
|
195
|
+
* while offline.
|
|
196
|
+
*/
|
|
197
|
+
private handleSubscribed() {
|
|
198
|
+
if (this.subscribedOnce && this.fetchedOnce) {
|
|
199
|
+
this.refresh();
|
|
200
|
+
}
|
|
201
|
+
this.subscribedOnce = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// urls marked seen on the server but still shown bold for this viewing
|
|
205
|
+
private seenUrls = new Set<string>();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Marks everything currently unseen as seen on the server, without any
|
|
209
|
+
* refetching - the socket keeps our items current and seen state is fully
|
|
210
|
+
* known here. Items stay bold for the current viewing and unbold on the
|
|
211
|
+
* next call (e.g. the next popup open). Resolves false if the server
|
|
212
|
+
* didn't record it, in which case nothing advances and the next call
|
|
213
|
+
* retries.
|
|
214
|
+
*/
|
|
215
|
+
public markSeen(): Promise<boolean> {
|
|
216
|
+
// unbold whatever was marked seen last time
|
|
217
|
+
if (this.seenUrls.size > 0) {
|
|
218
|
+
this.items = this.items.map((item) =>
|
|
219
|
+
this.seenUrls.has(item.url) ? { ...item, is_seen: true } : item
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const unseen = this.items.filter((item) => !item.is_seen);
|
|
224
|
+
if (unseen.length === 0 || !this.endpoint) {
|
|
225
|
+
return Promise.resolve(true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// only advance our seen state if the server actually recorded it,
|
|
229
|
+
// otherwise we'd unbold items the server still considers unseen
|
|
230
|
+
return deleteRequest(this.endpoint)
|
|
231
|
+
.then((response) => {
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
console.warn(
|
|
234
|
+
`failed marking notifications seen (${response.status})`
|
|
235
|
+
);
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
this.seenUrls = new Set(unseen.map((item) => item.url));
|
|
239
|
+
return true;
|
|
240
|
+
})
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
console.warn('failed marking notifications seen', error);
|
|
243
|
+
return false;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
137
247
|
public renderHeader(): TemplateResult {
|
|
138
248
|
return html`<div class="header">
|
|
139
249
|
<temba-icon name="notification"></temba-icon>
|
package/src/list/TembaList.ts
CHANGED
|
@@ -68,6 +68,9 @@ export class TembaList extends RapidElement {
|
|
|
68
68
|
|
|
69
69
|
reverseRefresh = true;
|
|
70
70
|
|
|
71
|
+
// subclasses that get realtime updates can opt out of interval polling
|
|
72
|
+
protected pollingEnabled = true;
|
|
73
|
+
|
|
71
74
|
// our next page from our endpoint
|
|
72
75
|
nextPage: string = null;
|
|
73
76
|
|
|
@@ -108,14 +111,17 @@ export class TembaList extends RapidElement {
|
|
|
108
111
|
|
|
109
112
|
public connectedCallback() {
|
|
110
113
|
super.connectedCallback();
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if (this.pollingEnabled) {
|
|
115
|
+
this.refreshInterval = setInterval(() => {
|
|
116
|
+
if (!this.paused) {
|
|
117
|
+
this.refreshKey = 'default_' + new Date().getTime();
|
|
118
|
+
}
|
|
119
|
+
}, DEFAULT_REFRESH);
|
|
120
|
+
}
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
public disconnectedCallback() {
|
|
124
|
+
super.disconnectedCallback();
|
|
119
125
|
clearInterval(this.refreshInterval);
|
|
120
126
|
}
|
|
121
127
|
|