@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/src/list/TembaMenu.ts
CHANGED
|
@@ -787,17 +787,40 @@ export class TembaMenu extends ResizeElement {
|
|
|
787
787
|
}
|
|
788
788
|
|
|
789
789
|
this.loadItems(item);
|
|
790
|
-
|
|
791
|
-
// refresh any embedded lists
|
|
792
|
-
this.shadowRoot
|
|
793
|
-
.querySelectorAll('temba-notification-list')
|
|
794
|
-
.forEach((ele: NotificationList) => {
|
|
795
|
-
ele.refresh();
|
|
796
|
-
});
|
|
797
790
|
}
|
|
798
791
|
|
|
799
792
|
public refresh = debounce(this.doRefresh, 200);
|
|
800
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Opening a popup with notifications in it marks them all seen - the badge
|
|
796
|
+
* clears now, the items unbold next open. No fetching; the socket keeps
|
|
797
|
+
* the list itself current.
|
|
798
|
+
*/
|
|
799
|
+
private handlePopupOpened(event: CustomEvent, menuItem: MenuItem) {
|
|
800
|
+
const dropdown = event.currentTarget as HTMLElement;
|
|
801
|
+
const lists = dropdown.querySelectorAll('temba-notification-list');
|
|
802
|
+
if (lists.length === 0) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// clear the badge optimistically, restoring it if marking seen fails so
|
|
807
|
+
// the unseen indicator isn't silently lost
|
|
808
|
+
const bubble = menuItem.bubble;
|
|
809
|
+
if (bubble) {
|
|
810
|
+
menuItem.bubble = null;
|
|
811
|
+
this.requestUpdate('root');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
lists.forEach((list: NotificationList) => {
|
|
815
|
+
list.markSeen().then((marked) => {
|
|
816
|
+
if (!marked && bubble && !menuItem.bubble) {
|
|
817
|
+
menuItem.bubble = bubble;
|
|
818
|
+
this.requestUpdate('root');
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
801
824
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
802
825
|
private loadItems(item: MenuItem, event: MouseEvent = null) {
|
|
803
826
|
if (item && item.endpoint) {
|
|
@@ -1111,6 +1134,13 @@ export class TembaMenu extends ResizeElement {
|
|
|
1111
1134
|
if (menuItem.type === 'temba-notification-list') {
|
|
1112
1135
|
return html`<temba-notification-list
|
|
1113
1136
|
endpoint=${menuItem.href}
|
|
1137
|
+
@temba-notification=${() => {
|
|
1138
|
+
// light the unseen badge on our popup item as they arrive
|
|
1139
|
+
if (parent && parent.bubble !== 'tomato') {
|
|
1140
|
+
parent.bubble = 'tomato';
|
|
1141
|
+
this.requestUpdate('root');
|
|
1142
|
+
}
|
|
1143
|
+
}}
|
|
1114
1144
|
></temba-notification-list>`;
|
|
1115
1145
|
}
|
|
1116
1146
|
|
|
@@ -1254,7 +1284,11 @@ export class TembaMenu extends ResizeElement {
|
|
|
1254
1284
|
|
|
1255
1285
|
if (menuItem.popup) {
|
|
1256
1286
|
return html`
|
|
1257
|
-
<temba-dropdown
|
|
1287
|
+
<temba-dropdown
|
|
1288
|
+
id="dd-${menuItem.id}"
|
|
1289
|
+
@temba-opened=${(event: CustomEvent) =>
|
|
1290
|
+
this.handlePopupOpened(event, menuItem)}
|
|
1291
|
+
>
|
|
1258
1292
|
<div slot="toggle">${item}</div>
|
|
1259
1293
|
|
|
1260
1294
|
<div slot="dropdown">
|
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,37 +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
|
-
|
|
636
|
+
const contact = this.currentContact.uuid;
|
|
637
|
+
topics.set(contact, { contact, ticket: null });
|
|
608
638
|
|
|
609
639
|
// on a contact switch the new ticket is set before the new contact has
|
|
610
640
|
// finished fetching, so hold off on the ticket channel until they match
|
|
611
641
|
// - a ticket paired with another contact's channel is denied server-side
|
|
612
|
-
const contactPending =
|
|
613
|
-
this.contact && this.contact !== this.currentContact.uuid;
|
|
642
|
+
const contactPending = this.contact && this.contact !== contact;
|
|
614
643
|
if (this.currentTicket && !contactPending) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
644
|
+
topics.set(`${contact}:${this.currentTicket.uuid}`, {
|
|
645
|
+
contact,
|
|
646
|
+
ticket: this.currentTicket.uuid
|
|
647
|
+
});
|
|
618
648
|
}
|
|
619
649
|
}
|
|
620
650
|
|
|
621
651
|
// drop subscriptions we no longer need
|
|
622
|
-
this.subscriptions.forEach((sub,
|
|
623
|
-
if (!
|
|
652
|
+
this.subscriptions.forEach((sub, key) => {
|
|
653
|
+
if (!topics.has(key)) {
|
|
624
654
|
sub.unsubscribe();
|
|
625
|
-
this.subscriptions.delete(
|
|
655
|
+
this.subscriptions.delete(key);
|
|
626
656
|
}
|
|
627
657
|
});
|
|
628
658
|
|
|
629
659
|
// add any new ones
|
|
630
|
-
|
|
631
|
-
if (!this.subscriptions.has(
|
|
660
|
+
topics.forEach((topic, key) => {
|
|
661
|
+
if (!this.subscriptions.has(key)) {
|
|
632
662
|
this.subscriptions.set(
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
663
|
+
key,
|
|
664
|
+
subscribeToContactHistory(
|
|
665
|
+
topic.contact,
|
|
666
|
+
topic.ticket,
|
|
636
667
|
(data: any) => this.handleSocketEvent(data),
|
|
637
668
|
// on every (re)subscribe fetch anything we might have missed
|
|
638
669
|
() => this.fetchMissedEvents()
|
|
@@ -649,12 +680,126 @@ export class ContactChat extends ContactStoreElement {
|
|
|
649
680
|
return;
|
|
650
681
|
}
|
|
651
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
|
+
|
|
652
689
|
const messages = this.createMessages({ events: [event], next: null });
|
|
653
690
|
if (messages.length > 0) {
|
|
654
691
|
this.chat.addMessages(messages, null, true);
|
|
655
692
|
}
|
|
656
693
|
}
|
|
657
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
|
+
|
|
658
803
|
private reset() {
|
|
659
804
|
if (this.chat) {
|
|
660
805
|
this.chat.reset();
|
|
@@ -664,6 +809,13 @@ export class ContactChat extends ContactStoreElement {
|
|
|
664
809
|
this.afterUUID = null;
|
|
665
810
|
this.fetchingMissed = false;
|
|
666
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
|
+
|
|
667
819
|
const compose = this.shadowRoot.querySelector('temba-compose') as Compose;
|
|
668
820
|
if (compose) {
|
|
669
821
|
compose.reset();
|
|
@@ -1067,6 +1219,19 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1067
1219
|
// convert to dates
|
|
1068
1220
|
event.created_on = new Date(event.created_on);
|
|
1069
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
|
+
|
|
1070
1235
|
if (
|
|
1071
1236
|
event.type === 'msg_created' ||
|
|
1072
1237
|
event.type === 'msg_received' ||
|
|
@@ -1286,6 +1451,7 @@ export class ContactChat extends ContactStoreElement {
|
|
|
1286
1451
|
shortcuts
|
|
1287
1452
|
min-height="75"
|
|
1288
1453
|
@temba-submitted=${this.handleSend.bind(this)}
|
|
1454
|
+
@temba-content-changed=${this.handleComposeChanged.bind(this)}
|
|
1289
1455
|
>
|
|
1290
1456
|
</temba-compose>
|
|
1291
1457
|
${this.errorMessage
|
|
@@ -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/store/Store.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
DirtyTrackable
|
|
23
23
|
} from '../interfaces';
|
|
24
24
|
import { RapidElement } from '../RapidElement';
|
|
25
|
+
import { setRealtimeContext } from '../live/Realtime';
|
|
25
26
|
import { lru } from 'tiny-lru';
|
|
26
27
|
import { DateTime } from 'luxon';
|
|
27
28
|
import { css, html } from 'lit';
|
|
@@ -108,6 +109,14 @@ export class Store extends RapidElement {
|
|
|
108
109
|
@property({ type: String, attribute: 'shortcuts' })
|
|
109
110
|
shortcutsEndpoint: string;
|
|
110
111
|
|
|
112
|
+
// current workspace and user uuids, used to address user-scoped realtime
|
|
113
|
+
// channels - hydrated by the page template
|
|
114
|
+
@property({ type: String })
|
|
115
|
+
org: string;
|
|
116
|
+
|
|
117
|
+
@property({ type: String })
|
|
118
|
+
user: string;
|
|
119
|
+
|
|
111
120
|
@property({ type: String })
|
|
112
121
|
brand = '';
|
|
113
122
|
|
|
@@ -266,6 +275,9 @@ export class Store extends RapidElement {
|
|
|
266
275
|
}
|
|
267
276
|
|
|
268
277
|
public firstUpdated() {
|
|
278
|
+
if (this.org && this.user) {
|
|
279
|
+
setRealtimeContext({ org: this.org, user: this.user });
|
|
280
|
+
}
|
|
269
281
|
this.reset();
|
|
270
282
|
}
|
|
271
283
|
|
package/src/utils.ts
CHANGED
|
@@ -339,6 +339,10 @@ export const postJSON = (url: string, payload: any): Promise<WebResponse> => {
|
|
|
339
339
|
return postUrl(url, JSON.stringify(payload), false, 'application/json');
|
|
340
340
|
};
|
|
341
341
|
|
|
342
|
+
export const deleteRequest = (url: string): Promise<Response> => {
|
|
343
|
+
return fetch(url, { method: 'DELETE', headers: getHeaders() });
|
|
344
|
+
};
|
|
345
|
+
|
|
342
346
|
export const postFormData = (
|
|
343
347
|
url: string,
|
|
344
348
|
formData: FormData,
|