@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.
@@ -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 id="dd-${menuItem.id}">
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">
@@ -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 { Chat, MessageType, ContactEvent } from '../display/Chat';
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 { subscribeToSocket, SocketSubscription } from './SocketService';
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 socket subscriptions by channel for the current contact (and ticket)
526
- private subscriptions = new Map<string, SocketSubscription>();
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 channels = new Set<string>();
634
+ const topics = new Map<string, { contact: string; ticket: string }>();
606
635
  if (this.isConnected && this.currentContact) {
607
- channels.add(`history:${this.currentContact.uuid}`);
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
- channels.add(
616
- `history:${this.currentContact.uuid}:${this.currentTicket.uuid}`
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, channel) => {
623
- if (!channels.has(channel)) {
652
+ this.subscriptions.forEach((sub, key) => {
653
+ if (!topics.has(key)) {
624
654
  sub.unsubscribe();
625
- this.subscriptions.delete(channel);
655
+ this.subscriptions.delete(key);
626
656
  }
627
657
  });
628
658
 
629
659
  // add any new ones
630
- channels.forEach((channel) => {
631
- if (!this.subscriptions.has(channel)) {
660
+ topics.forEach((topic, key) => {
661
+ if (!this.subscriptions.has(key)) {
632
662
  this.subscriptions.set(
633
- channel,
634
- subscribeToSocket(
635
- channel,
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;
@@ -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,