@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +1983 -865
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/Chat.ts +232 -18
  19. package/src/display/ProgressBar.ts +15 -2
  20. package/src/form/Compose.ts +31 -1
  21. package/src/form/ContactSearch.ts +229 -126
  22. package/src/interfaces.ts +73 -0
  23. package/src/layout/Card.ts +25 -0
  24. package/src/layout/CardLayout.ts +3 -3
  25. package/src/layout/HeaderBar.ts +4 -1
  26. package/src/list/BroadcastList.ts +912 -0
  27. package/src/list/ContentList.ts +46 -10
  28. package/src/list/FieldList.ts +1057 -0
  29. package/src/list/NotificationList.ts +131 -21
  30. package/src/list/SortableList.ts +7 -2
  31. package/src/list/TembaList.ts +19 -5
  32. package/src/list/TembaMenu.ts +47 -46
  33. package/src/live/CampaignEvents.ts +33 -19
  34. package/src/live/ContactChat.ts +190 -18
  35. package/src/live/ContactDetails.ts +20 -13
  36. package/src/live/ContactFieldEditor.ts +7 -3
  37. package/src/live/ContactStoreElement.ts +3 -1
  38. package/src/live/ContactTimeline.ts +28 -7
  39. package/src/live/Realtime.ts +133 -0
  40. package/src/live/SocketService.ts +24 -0
  41. package/src/locales/es.ts +4 -0
  42. package/src/locales/fr.ts +4 -0
  43. package/src/locales/pt.ts +4 -0
  44. package/src/store/Store.ts +12 -0
  45. package/src/utils.ts +4 -0
  46. package/static/svg/index.svg +1 -1
  47. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  48. package/static/svg/work/traced/star-filled.svg +1 -0
  49. package/static/svg/work/used/star-filled.svg +3 -0
  50. package/stress-test.js +3 -3
  51. package/temba-modules.ts +4 -0
  52. package/web-test-runner.config.mjs +1 -1
  53. package/xliff/es.xlf +12 -0
  54. package/xliff/fr.xlf +12 -0
  55. package/xliff/pt.xlf +12 -0
@@ -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,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 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}`);
608
- if (this.currentTicket) {
609
- channels.add(
610
- `history:${this.currentContact.uuid}:${this.currentTicket.uuid}`
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, channel) => {
617
- if (!channels.has(channel)) {
652
+ this.subscriptions.forEach((sub, key) => {
653
+ if (!topics.has(key)) {
618
654
  sub.unsubscribe();
619
- this.subscriptions.delete(channel);
655
+ this.subscriptions.delete(key);
620
656
  }
621
657
  });
622
658
 
623
659
  // add any new ones
624
- channels.forEach((channel) => {
625
- if (!this.subscriptions.has(channel)) {
660
+ topics.forEach((topic, key) => {
661
+ if (!this.subscriptions.has(key)) {
626
662
  this.subscriptions.set(
627
- channel,
628
- subscribeToSocket(
629
- channel,
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: 12px;
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.1em;
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: 12px;
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.1em;
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
- super.willUpdate(changed);
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: 7em 1em 4em;
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
- this.fireCustomEvent(CustomEventType.DetailsChanged, { count });
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
- <temba-icon name=${Icon.schedule} size="2"></temba-icon>
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-help">${this.lang_empty_help}</div>
816
- <a class="empty-link" href="/campaign/" onclick="goto(event, this)"
817
- >${this.lang_campaigns_link}</a
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`,