@nyaruka/temba-components 0.164.0 → 0.166.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 +20 -0
- package/DEV_DATA.md +11 -11
- package/README.md +4 -4
- package/TEST_OPTIMIZATION.md +8 -11
- package/dist/locales/es.js +4 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +4 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +4 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +1983 -865
- package/dist/temba-components.js.map +1 -1
- package/orca/setup.sh +2 -2
- package/package.json +13 -18
- package/scripts/dev-data-sync.mjs +4 -4
- package/src/Icons.ts +3 -2
- package/src/display/Chat.ts +232 -18
- package/src/display/ProgressBar.ts +15 -2
- package/src/form/Compose.ts +31 -1
- package/src/form/ContactSearch.ts +229 -126
- package/src/interfaces.ts +73 -0
- package/src/layout/Card.ts +25 -0
- package/src/layout/CardLayout.ts +3 -3
- package/src/layout/HeaderBar.ts +4 -1
- package/src/list/BroadcastList.ts +912 -0
- package/src/list/ContentList.ts +46 -10
- package/src/list/FieldList.ts +1057 -0
- package/src/list/NotificationList.ts +131 -21
- package/src/list/SortableList.ts +7 -2
- package/src/list/TembaList.ts +19 -5
- package/src/list/TembaMenu.ts +47 -46
- package/src/live/CampaignEvents.ts +33 -19
- package/src/live/ContactChat.ts +190 -18
- package/src/live/ContactDetails.ts +20 -13
- package/src/live/ContactFieldEditor.ts +7 -3
- package/src/live/ContactStoreElement.ts +3 -1
- package/src/live/ContactTimeline.ts +28 -7
- package/src/live/Realtime.ts +133 -0
- package/src/live/SocketService.ts +24 -0
- package/src/locales/es.ts +4 -0
- package/src/locales/fr.ts +4 -0
- package/src/locales/pt.ts +4 -0
- package/src/store/Store.ts +12 -0
- package/src/utils.ts +4 -0
- package/static/svg/index.svg +1 -1
- package/static/svg/packs/2-temba/star-filled.svg +3 -0
- package/static/svg/work/traced/star-filled.svg +1 -0
- package/static/svg/work/used/star-filled.svg +3 -0
- package/stress-test.js +3 -3
- package/temba-modules.ts +4 -0
- package/web-test-runner.config.mjs +1 -1
- package/xliff/es.xlf +12 -0
- package/xliff/fr.xlf +12 -0
- package/xliff/pt.xlf +12 -0
package/src/live/ContactChat.ts
CHANGED
|
@@ -27,7 +27,13 @@ import {
|
|
|
27
27
|
import { ContactStoreElement } from './ContactStoreElement';
|
|
28
28
|
import { Compose, ComposeValue } from '../form/Compose';
|
|
29
29
|
import { ContactHistoryPage } from '../events';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
Chat,
|
|
32
|
+
MessageType,
|
|
33
|
+
ContactEvent,
|
|
34
|
+
MsgEvent,
|
|
35
|
+
TypingEvent
|
|
36
|
+
} from '../display/Chat';
|
|
31
37
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
32
38
|
import { UserSelect } from '../form/select/UserSelect';
|
|
33
39
|
import { Select } from '../form/select/Select';
|
|
@@ -36,7 +42,12 @@ import {
|
|
|
36
42
|
renderTicketAction,
|
|
37
43
|
renderTicketAssigneeChanged
|
|
38
44
|
} from '../events/eventRenderers';
|
|
39
|
-
import {
|
|
45
|
+
import { publishToSocket } from './SocketService';
|
|
46
|
+
import { subscribeToContactHistory, RealtimeSubscription } from './Realtime';
|
|
47
|
+
|
|
48
|
+
// how often we re-publish typing_started while composing - just inside the
|
|
49
|
+
// tightest platform sustain interval (Telegram's indicator lapses after 5s)
|
|
50
|
+
const TYPING_PULSE_INTERVAL = 4000;
|
|
40
51
|
|
|
41
52
|
// re-export for backwards compatibility
|
|
42
53
|
export { renderTicketAction, renderTicketAssigneeChanged };
|
|
@@ -456,6 +467,11 @@ export class ContactChat extends ContactStoreElement {
|
|
|
456
467
|
@property({ type: String })
|
|
457
468
|
agent = '';
|
|
458
469
|
|
|
470
|
+
// uuid of the logged-in user, used to filter out our own typing events
|
|
471
|
+
// (publications are echoed to all subscribers, including the publisher)
|
|
472
|
+
@property({ type: String, attribute: 'user' })
|
|
473
|
+
userUuid = '';
|
|
474
|
+
|
|
459
475
|
@property({ type: Boolean })
|
|
460
476
|
blockFetching = false;
|
|
461
477
|
|
|
@@ -522,10 +538,22 @@ export class ContactChat extends ContactStoreElement {
|
|
|
522
538
|
beforeUUID: string = null; // for scrolling back through history
|
|
523
539
|
afterUUID: string = null; // newest event seen, for catch-up fetches
|
|
524
540
|
|
|
525
|
-
// live
|
|
526
|
-
private subscriptions = new Map<string,
|
|
541
|
+
// live history subscriptions by topic for the current contact (and ticket)
|
|
542
|
+
private subscriptions = new Map<string, RealtimeSubscription>();
|
|
527
543
|
private fetchingMissed = false;
|
|
528
544
|
|
|
545
|
+
// the contact's most recent incoming message, tracked for the external id
|
|
546
|
+
// that typing publications carry (WhatsApp expresses typing as an operation
|
|
547
|
+
// on the contact's last incoming message)
|
|
548
|
+
private lastIncomingMsgOn: Date = null;
|
|
549
|
+
private lastIncomingMsgExternalId: string = null;
|
|
550
|
+
|
|
551
|
+
// typing publication state - the pulse interval while the agent is
|
|
552
|
+
// composing, and whether publishing was denied for this conversation
|
|
553
|
+
private typingChannel: string = null;
|
|
554
|
+
private typingPulse: number = null;
|
|
555
|
+
private typingDisabled = false;
|
|
556
|
+
|
|
529
557
|
constructor() {
|
|
530
558
|
super();
|
|
531
559
|
}
|
|
@@ -560,6 +588,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
560
588
|
|
|
561
589
|
public disconnectedCallback() {
|
|
562
590
|
super.disconnectedCallback();
|
|
591
|
+
this.stopTyping();
|
|
563
592
|
this.unsubscribeAll();
|
|
564
593
|
}
|
|
565
594
|
|
|
@@ -602,31 +631,39 @@ export class ContactChat extends ContactStoreElement {
|
|
|
602
631
|
* continuously subscribed with no gap in delivery.
|
|
603
632
|
*/
|
|
604
633
|
private updateSubscriptions() {
|
|
605
|
-
const
|
|
634
|
+
const topics = new Map<string, { contact: string; ticket: string }>();
|
|
606
635
|
if (this.isConnected && this.currentContact) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
636
|
+
const contact = this.currentContact.uuid;
|
|
637
|
+
topics.set(contact, { contact, ticket: null });
|
|
638
|
+
|
|
639
|
+
// on a contact switch the new ticket is set before the new contact has
|
|
640
|
+
// finished fetching, so hold off on the ticket channel until they match
|
|
641
|
+
// - a ticket paired with another contact's channel is denied server-side
|
|
642
|
+
const contactPending = this.contact && this.contact !== contact;
|
|
643
|
+
if (this.currentTicket && !contactPending) {
|
|
644
|
+
topics.set(`${contact}:${this.currentTicket.uuid}`, {
|
|
645
|
+
contact,
|
|
646
|
+
ticket: this.currentTicket.uuid
|
|
647
|
+
});
|
|
612
648
|
}
|
|
613
649
|
}
|
|
614
650
|
|
|
615
651
|
// drop subscriptions we no longer need
|
|
616
|
-
this.subscriptions.forEach((sub,
|
|
617
|
-
if (!
|
|
652
|
+
this.subscriptions.forEach((sub, key) => {
|
|
653
|
+
if (!topics.has(key)) {
|
|
618
654
|
sub.unsubscribe();
|
|
619
|
-
this.subscriptions.delete(
|
|
655
|
+
this.subscriptions.delete(key);
|
|
620
656
|
}
|
|
621
657
|
});
|
|
622
658
|
|
|
623
659
|
// add any new ones
|
|
624
|
-
|
|
625
|
-
if (!this.subscriptions.has(
|
|
660
|
+
topics.forEach((topic, key) => {
|
|
661
|
+
if (!this.subscriptions.has(key)) {
|
|
626
662
|
this.subscriptions.set(
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
663
|
+
key,
|
|
664
|
+
subscribeToContactHistory(
|
|
665
|
+
topic.contact,
|
|
666
|
+
topic.ticket,
|
|
630
667
|
(data: any) => this.handleSocketEvent(data),
|
|
631
668
|
// on every (re)subscribe fetch anything we might have missed
|
|
632
669
|
() => this.fetchMissedEvents()
|
|
@@ -643,12 +680,126 @@ export class ContactChat extends ContactStoreElement {
|
|
|
643
680
|
return;
|
|
644
681
|
}
|
|
645
682
|
|
|
683
|
+
// typing events are ephemeral indicator state, not history
|
|
684
|
+
if (event.type === 'typing_started' || event.type === 'typing_stopped') {
|
|
685
|
+
this.handleTypingEvent(event);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
646
689
|
const messages = this.createMessages({ events: [event], next: null });
|
|
647
690
|
if (messages.length > 0) {
|
|
648
691
|
this.chat.addMessages(messages, null, true);
|
|
649
692
|
}
|
|
650
693
|
}
|
|
651
694
|
|
|
695
|
+
private handleTypingEvent(event: TypingEvent) {
|
|
696
|
+
// our own publications are echoed back to us - ignore them
|
|
697
|
+
if (event._user && this.userUuid && event._user.uuid === this.userUuid) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
event.created_on = new Date(event.created_on);
|
|
702
|
+
this.resolveUserAvatar(event);
|
|
703
|
+
|
|
704
|
+
if (event.type === 'typing_started') {
|
|
705
|
+
this.chat.setTyping(event);
|
|
706
|
+
} else {
|
|
707
|
+
this.chat.clearTyping(event);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Fired as the agent edits the compose box. Composing (any text present)
|
|
713
|
+
* keeps typing_started pulses going on the contact's history channel;
|
|
714
|
+
* emptying the box (including via a send) publishes typing_stopped.
|
|
715
|
+
*/
|
|
716
|
+
private handleComposeChanged(evt: CustomEvent) {
|
|
717
|
+
// temba-compose's ContentChanged detail is its langValues map keyed by
|
|
718
|
+
// language, and it drops a language entry when that value empties. Derive
|
|
719
|
+
// composing across all languages so this doesn't hinge on a hardcoded key.
|
|
720
|
+
const composing = Object.values(evt.detail || {}).some((v: any) =>
|
|
721
|
+
v?.text?.trim()
|
|
722
|
+
);
|
|
723
|
+
if (composing) {
|
|
724
|
+
this.startTyping();
|
|
725
|
+
} else {
|
|
726
|
+
this.stopTyping();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private getTypingPayload(type: string) {
|
|
731
|
+
const payload: any = { type };
|
|
732
|
+
if (this.lastIncomingMsgExternalId) {
|
|
733
|
+
payload.msg_external_id = this.lastIncomingMsgExternalId;
|
|
734
|
+
}
|
|
735
|
+
return payload;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private startTyping() {
|
|
739
|
+
if (this.typingDisabled || this.typingPulse || !this.currentContact) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// publications are only allowed on the contact-level channel
|
|
744
|
+
this.typingChannel = `history:${this.currentContact.uuid}`;
|
|
745
|
+
this.sendTypingPulse();
|
|
746
|
+
this.typingPulse = window.setInterval(
|
|
747
|
+
() => this.sendTypingPulse(),
|
|
748
|
+
TYPING_PULSE_INTERVAL
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private sendTypingPulse() {
|
|
753
|
+
// the interval can fire once after clearTypingPulse() nulled the channel
|
|
754
|
+
if (!this.typingChannel) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const channel = this.typingChannel;
|
|
758
|
+
publishToSocket(channel, this.getTypingPayload('typing_started')).catch(
|
|
759
|
+
(error: any) => {
|
|
760
|
+
// a temporary server error just costs us this pulse - a denial means
|
|
761
|
+
// something is genuinely wrong with this conversation, so silently
|
|
762
|
+
// stop pulsing for it. The handler runs async, so don't clobber a
|
|
763
|
+
// fresh conversation's state with an error for a stale one.
|
|
764
|
+
if (!error?.temporary && this.isCurrentTypingChannel(channel)) {
|
|
765
|
+
this.typingDisabled = true;
|
|
766
|
+
this.clearTypingPulse();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// whether the channel belongs to the contact currently being viewed
|
|
773
|
+
private isCurrentTypingChannel(channel: string) {
|
|
774
|
+
return channel === `history:${this.currentContact?.uuid}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private clearTypingPulse() {
|
|
778
|
+
if (this.typingPulse) {
|
|
779
|
+
window.clearInterval(this.typingPulse);
|
|
780
|
+
this.typingPulse = null;
|
|
781
|
+
}
|
|
782
|
+
this.typingChannel = null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private stopTyping() {
|
|
786
|
+
if (!this.typingPulse) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const channel = this.typingChannel;
|
|
791
|
+
this.clearTypingPulse();
|
|
792
|
+
publishToSocket(channel, this.getTypingPayload('typing_stopped')).catch(
|
|
793
|
+
(error: any) => {
|
|
794
|
+
// on a contact switch this stop targets the old conversation - a
|
|
795
|
+
// denial for it must not disable typing for the new one
|
|
796
|
+
if (!error?.temporary && this.isCurrentTypingChannel(channel)) {
|
|
797
|
+
this.typingDisabled = true;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
652
803
|
private reset() {
|
|
653
804
|
if (this.chat) {
|
|
654
805
|
this.chat.reset();
|
|
@@ -658,6 +809,13 @@ export class ContactChat extends ContactStoreElement {
|
|
|
658
809
|
this.afterUUID = null;
|
|
659
810
|
this.fetchingMissed = false;
|
|
660
811
|
|
|
812
|
+
// let the old conversation know we stopped composing and start the new
|
|
813
|
+
// one with a clean typing slate
|
|
814
|
+
this.stopTyping();
|
|
815
|
+
this.typingDisabled = false;
|
|
816
|
+
this.lastIncomingMsgOn = null;
|
|
817
|
+
this.lastIncomingMsgExternalId = null;
|
|
818
|
+
|
|
661
819
|
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
|
|
662
820
|
if (compose) {
|
|
663
821
|
compose.reset();
|
|
@@ -1061,6 +1219,19 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1061
1219
|
// convert to dates
|
|
1062
1220
|
event.created_on = new Date(event.created_on);
|
|
1063
1221
|
|
|
1222
|
+
// track the contact's most recent incoming message for the external
|
|
1223
|
+
// id our typing publications carry
|
|
1224
|
+
if (event.type === 'msg_received') {
|
|
1225
|
+
if (
|
|
1226
|
+
!this.lastIncomingMsgOn ||
|
|
1227
|
+
event.created_on > this.lastIncomingMsgOn
|
|
1228
|
+
) {
|
|
1229
|
+
this.lastIncomingMsgOn = event.created_on;
|
|
1230
|
+
this.lastIncomingMsgExternalId =
|
|
1231
|
+
(event as MsgEvent).msg?.external_id || null;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1064
1235
|
if (
|
|
1065
1236
|
event.type === 'msg_created' ||
|
|
1066
1237
|
event.type === 'msg_received' ||
|
|
@@ -1280,6 +1451,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1280
1451
|
shortcuts
|
|
1281
1452
|
min-height="75"
|
|
1282
1453
|
@temba-submitted=${this.handleSend.bind(this)}
|
|
1454
|
+
@temba-content-changed=${this.handleComposeChanged.bind(this)}
|
|
1283
1455
|
>
|
|
1284
1456
|
</temba-compose>
|
|
1285
1457
|
${this.errorMessage
|
|
@@ -38,7 +38,7 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
38
38
|
|
|
39
39
|
.row .label {
|
|
40
40
|
color: var(--text-2);
|
|
41
|
-
font-size:
|
|
41
|
+
font-size: 11px;
|
|
42
42
|
font-weight: var(--w-medium);
|
|
43
43
|
margin-top: 0.25em;
|
|
44
44
|
margin-left: 0.25em;
|
|
@@ -46,7 +46,7 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
46
46
|
|
|
47
47
|
.row .value {
|
|
48
48
|
margin-left: 0.25em;
|
|
49
|
-
margin-top: 0.
|
|
49
|
+
margin-top: 0.35em;
|
|
50
50
|
min-height: 1.75em;
|
|
51
51
|
display: flex;
|
|
52
52
|
flex-wrap: wrap;
|
|
@@ -55,6 +55,12 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
55
55
|
|
|
56
56
|
.group {
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/* the last row needs no separator — the card edge closes it off */
|
|
60
|
+
temba-contact-field.last-field {
|
|
61
|
+
--contact-field-separator: none;
|
|
62
|
+
margin-bottom: 0;
|
|
63
|
+
}
|
|
58
64
|
`;
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -70,6 +76,17 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
70
76
|
|
|
71
77
|
return html`
|
|
72
78
|
<div class="wrapper">
|
|
79
|
+
${this.data.urns.map((urn) => {
|
|
80
|
+
let scheme = SCHEMES[urn.scheme];
|
|
81
|
+
if (!scheme) {
|
|
82
|
+
scheme = capitalize(urn.scheme as any);
|
|
83
|
+
}
|
|
84
|
+
return html`<temba-contact-field
|
|
85
|
+
name=${scheme}
|
|
86
|
+
value=${urn.display || urn.path}
|
|
87
|
+
disabled
|
|
88
|
+
></temba-contact-field>`;
|
|
89
|
+
})}
|
|
73
90
|
${this.data.groups.length > 0
|
|
74
91
|
? html` <div class="row">
|
|
75
92
|
<div class="label">Groups</div>
|
|
@@ -89,17 +106,6 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
89
106
|
</div>
|
|
90
107
|
</div>`
|
|
91
108
|
: null}
|
|
92
|
-
${this.data.urns.map((urn) => {
|
|
93
|
-
let scheme = SCHEMES[urn.scheme];
|
|
94
|
-
if (!scheme) {
|
|
95
|
-
scheme = capitalize(urn.scheme as any);
|
|
96
|
-
}
|
|
97
|
-
return html`<temba-contact-field
|
|
98
|
-
name=${scheme}
|
|
99
|
-
value=${urn.display || urn.path}
|
|
100
|
-
disabled
|
|
101
|
-
></temba-contact-field>`;
|
|
102
|
-
})}
|
|
103
109
|
${this.data.ref
|
|
104
110
|
? html`<temba-contact-field
|
|
105
111
|
name="Ref"
|
|
@@ -128,6 +134,7 @@ export class ContactDetails extends ContactStoreElement {
|
|
|
128
134
|
disabled
|
|
129
135
|
></temba-contact-field>
|
|
130
136
|
<temba-contact-field
|
|
137
|
+
class="last-field"
|
|
131
138
|
name="Last Seen"
|
|
132
139
|
value=${this.data.last_seen_on}
|
|
133
140
|
type="datetime"
|
|
@@ -61,7 +61,7 @@ export class ContactFieldEditor extends RapidElement {
|
|
|
61
61
|
--color-widget-bg-focused: #fff;
|
|
62
62
|
--widget-box-shadow: none;
|
|
63
63
|
padding-bottom: 0.6em;
|
|
64
|
-
border-bottom: 1px solid #ececec;
|
|
64
|
+
border-bottom: var(--contact-field-separator, 1px solid #ececec);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
.wrapper.disabled {
|
|
@@ -108,9 +108,12 @@ export class ContactFieldEditor extends RapidElement {
|
|
|
108
108
|
text-overflow: ellipsis;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/* read-only rows set the label clearly apart from the value — the
|
|
112
|
+
same quiet label treatment as .field-label on the editable rows,
|
|
113
|
+
a touch smaller, over a full-color value */
|
|
111
114
|
.label .name {
|
|
112
115
|
color: var(--text-2);
|
|
113
|
-
font-size:
|
|
116
|
+
font-size: 11px;
|
|
114
117
|
font-weight: var(--w-medium);
|
|
115
118
|
}
|
|
116
119
|
|
|
@@ -120,8 +123,9 @@ export class ContactFieldEditor extends RapidElement {
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
.disabled .value {
|
|
126
|
+
color: var(--text-1);
|
|
123
127
|
margin-left: 0.25em;
|
|
124
|
-
margin-top: 0.
|
|
128
|
+
margin-top: 0.35em;
|
|
125
129
|
min-height: 1.75em;
|
|
126
130
|
}
|
|
127
131
|
|
|
@@ -66,7 +66,8 @@ export class ContactStoreElement extends EndpointMonitorElement {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
public willUpdate(changed: PropertyValues): void {
|
|
69
|
-
|
|
69
|
+
// derive our url before the base class runs so it sees the url change
|
|
70
|
+
// in this same pass (clearing stale data when the contact is unset)
|
|
70
71
|
if (changed.has('contact') || changed.has('endpoint')) {
|
|
71
72
|
if (this.contact) {
|
|
72
73
|
this.url = `${this.endpoint}${this.contact}`;
|
|
@@ -74,5 +75,6 @@ export class ContactStoreElement extends EndpointMonitorElement {
|
|
|
74
75
|
this.url = null;
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
super.willUpdate(changed);
|
|
77
79
|
}
|
|
78
80
|
}
|
|
@@ -115,7 +115,9 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
115
115
|
flex-direction: column;
|
|
116
116
|
align-items: center;
|
|
117
117
|
text-align: center;
|
|
118
|
-
padding
|
|
118
|
+
/* a host card sets --empty-padding to compact this treatment —
|
|
119
|
+
the default suits a full-height tab pane */
|
|
120
|
+
padding: var(--empty-padding, 7em 1em 4em);
|
|
119
121
|
color: var(--text-color);
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -124,9 +126,17 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
124
126
|
--icon-color: var(--text-3, #7b8593);
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/* the icon / blurb / link around the title — a host card hides
|
|
130
|
+
these so the empty card is just the stylized message */
|
|
131
|
+
.empty-extras {
|
|
132
|
+
display: var(--empty-extras-display, contents);
|
|
133
|
+
}
|
|
134
|
+
|
|
127
135
|
.empty-title {
|
|
128
136
|
font-weight: 600;
|
|
129
137
|
margin-bottom: 0.4em;
|
|
138
|
+
font-size: var(--empty-title-size, inherit);
|
|
139
|
+
color: var(--empty-title-color, inherit);
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
.empty-help {
|
|
@@ -547,7 +557,14 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
547
557
|
: Array.isArray(this.data.future)
|
|
548
558
|
? this.data.future.length
|
|
549
559
|
: 0;
|
|
550
|
-
|
|
560
|
+
// empty mirrors the render()'s empty-state condition so a host
|
|
561
|
+
// card can drop its chrome entirely when there's nothing to show
|
|
562
|
+
const empty =
|
|
563
|
+
count === 0 &&
|
|
564
|
+
!(Array.isArray(this.data.campaigns) && this.data.campaigns.length) &&
|
|
565
|
+
!(Array.isArray(this.data.future) && this.data.future.length) &&
|
|
566
|
+
!(Array.isArray(this.data.past) && this.data.past.length);
|
|
567
|
+
this.fireCustomEvent(CustomEventType.DetailsChanged, { count, empty });
|
|
551
568
|
}
|
|
552
569
|
}
|
|
553
570
|
}
|
|
@@ -810,12 +827,16 @@ export class ContactTimeline extends EndpointMonitorElement {
|
|
|
810
827
|
) {
|
|
811
828
|
return html`<div class="empty">
|
|
812
829
|
<slot name="empty">
|
|
813
|
-
<
|
|
830
|
+
<div class="empty-extras">
|
|
831
|
+
<temba-icon name=${Icon.schedule} size="2"></temba-icon>
|
|
832
|
+
</div>
|
|
814
833
|
<div class="empty-title">${this.lang_empty}</div>
|
|
815
|
-
<div class="empty-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
834
|
+
<div class="empty-extras">
|
|
835
|
+
<div class="empty-help">${this.lang_empty_help}</div>
|
|
836
|
+
<a class="empty-link" href="/campaign/" onclick="goto(event, this)"
|
|
837
|
+
>${this.lang_campaigns_link}</a
|
|
838
|
+
>
|
|
839
|
+
</div>
|
|
819
840
|
</slot>
|
|
820
841
|
</div>`;
|
|
821
842
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Notification } from '../interfaces';
|
|
2
|
+
import {
|
|
3
|
+
PublicationHandler,
|
|
4
|
+
SocketSubscription,
|
|
5
|
+
subscribeToSocket
|
|
6
|
+
} from './SocketService';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Typed access to our realtime topics. SocketService owns the shared
|
|
10
|
+
* connection and per-channel fan-out; this module owns how topics map to
|
|
11
|
+
* channel names, including the page identity (org and user uuids) needed to
|
|
12
|
+
* address user-scoped channels.
|
|
13
|
+
*
|
|
14
|
+
* The identity arrives via temba-store (hydrated from the page template), so
|
|
15
|
+
* user-scoped subscriptions requested before the store mounts are queued and
|
|
16
|
+
* activate when the context is set. On pages with no authenticated context
|
|
17
|
+
* they simply never activate.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface RealtimeSubscription {
|
|
21
|
+
unsubscribe(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RealtimeContext {
|
|
25
|
+
org: string;
|
|
26
|
+
user: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PendingSubscription {
|
|
30
|
+
resolveChannel: (ctx: RealtimeContext) => string;
|
|
31
|
+
onPublication: PublicationHandler;
|
|
32
|
+
onSubscribed?: () => void;
|
|
33
|
+
sub: SocketSubscription;
|
|
34
|
+
cancelled: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let context: RealtimeContext = null;
|
|
38
|
+
const pending: PendingSubscription[] = [];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sets the page's realtime identity, flushing any subscriptions that were
|
|
42
|
+
* waiting on it. Set once per page load - an org switch is a full page
|
|
43
|
+
* load, so a real page never changes or clears its context. Passing null is
|
|
44
|
+
* a full reset for tests: it discards the context AND any still-queued
|
|
45
|
+
* subscriptions, so handles handed out before the reset never activate.
|
|
46
|
+
* Returns the previous context.
|
|
47
|
+
*/
|
|
48
|
+
export const setRealtimeContext = (
|
|
49
|
+
ctx: RealtimeContext | null
|
|
50
|
+
): RealtimeContext | null => {
|
|
51
|
+
const previous = context;
|
|
52
|
+
context = ctx;
|
|
53
|
+
if (ctx) {
|
|
54
|
+
while (pending.length > 0) {
|
|
55
|
+
const p = pending.shift();
|
|
56
|
+
if (!p.cancelled) {
|
|
57
|
+
p.sub = subscribeToSocket(
|
|
58
|
+
p.resolveChannel(ctx),
|
|
59
|
+
p.onPublication,
|
|
60
|
+
p.onSubscribed
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
pending.length = 0;
|
|
66
|
+
}
|
|
67
|
+
return previous;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const subscribeWhenReady = (
|
|
71
|
+
resolveChannel: (ctx: RealtimeContext) => string,
|
|
72
|
+
onPublication: PublicationHandler,
|
|
73
|
+
onSubscribed?: () => void
|
|
74
|
+
): RealtimeSubscription => {
|
|
75
|
+
if (context) {
|
|
76
|
+
return subscribeToSocket(
|
|
77
|
+
resolveChannel(context),
|
|
78
|
+
onPublication,
|
|
79
|
+
onSubscribed
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const p: PendingSubscription = {
|
|
84
|
+
resolveChannel,
|
|
85
|
+
onPublication,
|
|
86
|
+
onSubscribed,
|
|
87
|
+
sub: null,
|
|
88
|
+
cancelled: false
|
|
89
|
+
};
|
|
90
|
+
pending.push(p);
|
|
91
|
+
return {
|
|
92
|
+
unsubscribe: () => {
|
|
93
|
+
p.cancelled = true;
|
|
94
|
+
if (p.sub) {
|
|
95
|
+
p.sub.unsubscribe();
|
|
96
|
+
p.sub = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The current user's notifications in the current workspace. onSubscribed
|
|
104
|
+
* fires on every (re)subscribe, including after reconnects, so subscribers
|
|
105
|
+
* can catch up on anything missed while offline.
|
|
106
|
+
*/
|
|
107
|
+
export const subscribeToNotifications = (
|
|
108
|
+
onNotification: (notification: Notification) => void,
|
|
109
|
+
onSubscribed?: () => void
|
|
110
|
+
): RealtimeSubscription => {
|
|
111
|
+
return subscribeWhenReady(
|
|
112
|
+
(ctx) => `notifications:${ctx.org}:${ctx.user}`,
|
|
113
|
+
(data) => onNotification(data as Notification),
|
|
114
|
+
onSubscribed
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A contact's history events, or a ticket's detail events when a ticket is
|
|
120
|
+
* given. Needs no page context so subscribes immediately.
|
|
121
|
+
*/
|
|
122
|
+
export const subscribeToContactHistory = (
|
|
123
|
+
contact: string,
|
|
124
|
+
ticket: string | null,
|
|
125
|
+
onEvent: (event: any) => void,
|
|
126
|
+
onSubscribed?: () => void
|
|
127
|
+
): RealtimeSubscription => {
|
|
128
|
+
return subscribeToSocket(
|
|
129
|
+
ticket ? `history:${contact}:${ticket}` : `history:${contact}`,
|
|
130
|
+
onEvent,
|
|
131
|
+
onSubscribed
|
|
132
|
+
);
|
|
133
|
+
};
|
|
@@ -34,6 +34,8 @@ export interface SocketProvider {
|
|
|
34
34
|
onPublication: PublicationHandler,
|
|
35
35
|
onSubscribed?: () => void
|
|
36
36
|
): SocketSubscription;
|
|
37
|
+
|
|
38
|
+
publish(channel: string, data: any): Promise<void>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
interface ChannelEntry {
|
|
@@ -59,6 +61,24 @@ export class SocketManager implements SocketProvider {
|
|
|
59
61
|
});
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Publishes data on a channel. The server proxies client publications
|
|
66
|
+
* (e.g. typing events on history channels) to mailroom for authorization
|
|
67
|
+
* and fan-out; a rejection means the publication was denied.
|
|
68
|
+
*/
|
|
69
|
+
public publish(channel: string, data: any): Promise<void> {
|
|
70
|
+
if (!this.socket) {
|
|
71
|
+
this.socket = this.createSocket();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// prefer the channel's live subscription when we have one
|
|
75
|
+
const entry = this.channels.get(channel);
|
|
76
|
+
const published = entry
|
|
77
|
+
? entry.sub.publish(data)
|
|
78
|
+
: this.socket.publish(channel, data);
|
|
79
|
+
return published.then(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
public subscribe(
|
|
63
83
|
channel: string,
|
|
64
84
|
onPublication: PublicationHandler,
|
|
@@ -150,6 +170,10 @@ export const subscribeToSocket = (
|
|
|
150
170
|
);
|
|
151
171
|
};
|
|
152
172
|
|
|
173
|
+
export const publishToSocket = (channel: string, data: any): Promise<void> => {
|
|
174
|
+
return (provider || getManager()).publish(channel, data);
|
|
175
|
+
};
|
|
176
|
+
|
|
153
177
|
// for tests to swap in a mock provider, returns the previous provider
|
|
154
178
|
export const setSocketProvider = (newProvider: SocketProvider) => {
|
|
155
179
|
const previous = provider;
|
package/src/locales/es.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
's73b4d70c02f4b4e0': `No options`,
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
15
|
's81f17cfc89a04338': `Interrupt flow`,
|
|
16
|
+
's122d4de68bcfcdf4': `It's okay to restart`,
|
|
17
|
+
's3eb2567092b4d7c1': `from the beginning`,
|
|
18
|
+
's28f37776b3901438': `It's okay to interrupt`,
|
|
19
|
+
's6838d20b10f7512a': `and start this one`,
|
|
16
20
|
's8f02e3a18ffc083a': `Are not currently in a flow`,
|
|
17
21
|
's638236250662c6b3': `Have sent a message in the last`,
|
|
18
22
|
's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
|
package/src/locales/fr.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
'scf1453991c986b25': `Tab to complete, enter to select`,
|
|
14
14
|
'sbc913d7dc0f33877': `to add`,
|
|
15
15
|
's81f17cfc89a04338': `Interrupt flow`,
|
|
16
|
+
's122d4de68bcfcdf4': `It's okay to restart`,
|
|
17
|
+
's3eb2567092b4d7c1': `from the beginning`,
|
|
18
|
+
's28f37776b3901438': `It's okay to interrupt`,
|
|
19
|
+
's6838d20b10f7512a': `and start this one`,
|
|
16
20
|
's8f02e3a18ffc083a': `Are not currently in a flow`,
|
|
17
21
|
's638236250662c6b3': `Have sent a message in the last`,
|
|
18
22
|
's4788ee206c4570c7': `Have not started this flow in the last 90 days`,
|