@repzo/widget-react-native 1.0.9 → 1.2.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 (191) hide show
  1. package/CHANGELOG.md +400 -0
  2. package/README.md +104 -5
  3. package/lib/commonjs/agent-ui-blocks.js +2 -1
  4. package/lib/commonjs/agent-ui-blocks.js.map +1 -1
  5. package/lib/commonjs/api/client.js +25 -0
  6. package/lib/commonjs/api/client.js.map +1 -1
  7. package/lib/commonjs/attachments/pickers.js +3 -6
  8. package/lib/commonjs/attachments/pickers.js.map +1 -1
  9. package/lib/commonjs/device-id.js +31 -3
  10. package/lib/commonjs/device-id.js.map +1 -1
  11. package/lib/commonjs/i18n.js +40 -4
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/locales.js +34 -1
  15. package/lib/commonjs/locales.js.map +1 -1
  16. package/lib/commonjs/push/push-notifications.js +9 -8
  17. package/lib/commonjs/push/push-notifications.js.map +1 -1
  18. package/lib/commonjs/state/widget-store.js +507 -26
  19. package/lib/commonjs/state/widget-store.js.map +1 -1
  20. package/lib/commonjs/storage.js +13 -1
  21. package/lib/commonjs/storage.js.map +1 -1
  22. package/lib/commonjs/types.js +20 -2
  23. package/lib/commonjs/types.js.map +1 -1
  24. package/lib/commonjs/ui/WidgetProvider.js +138 -49
  25. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  26. package/lib/commonjs/ui/components/AgentUiBlocks.js +35 -15
  27. package/lib/commonjs/ui/components/AgentUiBlocks.js.map +1 -1
  28. package/lib/commonjs/ui/components/Launcher.js +4 -1
  29. package/lib/commonjs/ui/components/Launcher.js.map +1 -1
  30. package/lib/commonjs/ui/components/Markdown.js +32 -2
  31. package/lib/commonjs/ui/components/Markdown.js.map +1 -1
  32. package/lib/commonjs/ui/components/Post.js +1241 -0
  33. package/lib/commonjs/ui/components/Post.js.map +1 -0
  34. package/lib/commonjs/ui/components/RatingCard.js +6 -3
  35. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  36. package/lib/commonjs/ui/hooks/use-widget-insets.js +0 -1
  37. package/lib/commonjs/ui/hooks/use-widget-insets.js.map +1 -1
  38. package/lib/commonjs/ui/screens/chat/ChatView.js +67 -29
  39. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  40. package/lib/commonjs/ui/utils/html.js +9 -1
  41. package/lib/commonjs/ui/utils/html.js.map +1 -1
  42. package/lib/commonjs/ui/utils/native-gradient.js +0 -1
  43. package/lib/commonjs/ui/utils/native-gradient.js.map +1 -1
  44. package/lib/commonjs/ui/utils/post-html.js +276 -0
  45. package/lib/commonjs/ui/utils/post-html.js.map +1 -0
  46. package/lib/commonjs/ui/utils/safe-url.js +16 -0
  47. package/lib/commonjs/ui/utils/safe-url.js.map +1 -1
  48. package/lib/module/agent-ui-blocks.js +2 -1
  49. package/lib/module/agent-ui-blocks.js.map +1 -1
  50. package/lib/module/api/client.js +25 -0
  51. package/lib/module/api/client.js.map +1 -1
  52. package/lib/module/attachments/pickers.js +3 -6
  53. package/lib/module/attachments/pickers.js.map +1 -1
  54. package/lib/module/device-id.js +31 -3
  55. package/lib/module/device-id.js.map +1 -1
  56. package/lib/module/i18n.js +40 -4
  57. package/lib/module/i18n.js.map +1 -1
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/locales.js +34 -1
  60. package/lib/module/locales.js.map +1 -1
  61. package/lib/module/push/push-notifications.js +9 -8
  62. package/lib/module/push/push-notifications.js.map +1 -1
  63. package/lib/module/state/widget-store.js +507 -25
  64. package/lib/module/state/widget-store.js.map +1 -1
  65. package/lib/module/storage.js +13 -1
  66. package/lib/module/storage.js.map +1 -1
  67. package/lib/module/types.js +20 -2
  68. package/lib/module/types.js.map +1 -1
  69. package/lib/module/ui/WidgetProvider.js +139 -50
  70. package/lib/module/ui/WidgetProvider.js.map +1 -1
  71. package/lib/module/ui/components/AgentUiBlocks.js +35 -15
  72. package/lib/module/ui/components/AgentUiBlocks.js.map +1 -1
  73. package/lib/module/ui/components/Launcher.js +4 -1
  74. package/lib/module/ui/components/Launcher.js.map +1 -1
  75. package/lib/module/ui/components/Markdown.js +32 -3
  76. package/lib/module/ui/components/Markdown.js.map +1 -1
  77. package/lib/module/ui/components/Post.js +1236 -0
  78. package/lib/module/ui/components/Post.js.map +1 -0
  79. package/lib/module/ui/components/RatingCard.js +6 -3
  80. package/lib/module/ui/components/RatingCard.js.map +1 -1
  81. package/lib/module/ui/hooks/use-widget-insets.js +0 -1
  82. package/lib/module/ui/hooks/use-widget-insets.js.map +1 -1
  83. package/lib/module/ui/screens/chat/ChatView.js +68 -30
  84. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  85. package/lib/module/ui/utils/html.js +9 -2
  86. package/lib/module/ui/utils/html.js.map +1 -1
  87. package/lib/module/ui/utils/native-gradient.js +0 -1
  88. package/lib/module/ui/utils/native-gradient.js.map +1 -1
  89. package/lib/module/ui/utils/post-html.js +271 -0
  90. package/lib/module/ui/utils/post-html.js.map +1 -0
  91. package/lib/module/ui/utils/safe-url.js +15 -0
  92. package/lib/module/ui/utils/safe-url.js.map +1 -1
  93. package/lib/typescript/commonjs/agent-ui-blocks.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/api/client.d.ts +19 -1
  95. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/device-id.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/i18n.d.ts +35 -1
  99. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/index.d.ts +1 -1
  101. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/push/push-notifications.d.ts +6 -2
  104. package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/state/widget-store.d.ts +97 -8
  106. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/types.d.ts +91 -8
  109. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts +14 -0
  112. package/lib/typescript/commonjs/ui/components/AgentUiBlocks.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/components/Markdown.d.ts +8 -0
  115. package/lib/typescript/commonjs/ui/components/Markdown.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/components/Post.d.ts +69 -0
  117. package/lib/typescript/commonjs/ui/components/Post.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/hooks/use-widget-insets.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +10 -0
  122. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/ui/utils/html.d.ts +1 -0
  124. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/ui/utils/native-gradient.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/utils/post-html.d.ts +54 -0
  127. package/lib/typescript/commonjs/ui/utils/post-html.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +6 -0
  129. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -1
  130. package/lib/typescript/module/agent-ui-blocks.d.ts.map +1 -1
  131. package/lib/typescript/module/api/client.d.ts +19 -1
  132. package/lib/typescript/module/api/client.d.ts.map +1 -1
  133. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  134. package/lib/typescript/module/device-id.d.ts.map +1 -1
  135. package/lib/typescript/module/i18n.d.ts +35 -1
  136. package/lib/typescript/module/i18n.d.ts.map +1 -1
  137. package/lib/typescript/module/index.d.ts +1 -1
  138. package/lib/typescript/module/index.d.ts.map +1 -1
  139. package/lib/typescript/module/locales.d.ts.map +1 -1
  140. package/lib/typescript/module/push/push-notifications.d.ts +6 -2
  141. package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
  142. package/lib/typescript/module/state/widget-store.d.ts +97 -8
  143. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  144. package/lib/typescript/module/storage.d.ts.map +1 -1
  145. package/lib/typescript/module/types.d.ts +91 -8
  146. package/lib/typescript/module/types.d.ts.map +1 -1
  147. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts +14 -0
  149. package/lib/typescript/module/ui/components/AgentUiBlocks.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
  151. package/lib/typescript/module/ui/components/Markdown.d.ts +8 -0
  152. package/lib/typescript/module/ui/components/Markdown.d.ts.map +1 -1
  153. package/lib/typescript/module/ui/components/Post.d.ts +69 -0
  154. package/lib/typescript/module/ui/components/Post.d.ts.map +1 -0
  155. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/hooks/use-widget-insets.d.ts.map +1 -1
  157. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  158. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +10 -0
  159. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  160. package/lib/typescript/module/ui/utils/html.d.ts +1 -0
  161. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  162. package/lib/typescript/module/ui/utils/native-gradient.d.ts.map +1 -1
  163. package/lib/typescript/module/ui/utils/post-html.d.ts +54 -0
  164. package/lib/typescript/module/ui/utils/post-html.d.ts.map +1 -0
  165. package/lib/typescript/module/ui/utils/safe-url.d.ts +6 -0
  166. package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -1
  167. package/package.json +14 -4
  168. package/src/agent-ui-blocks.ts +2 -1
  169. package/src/api/client.ts +27 -0
  170. package/src/attachments/pickers.ts +3 -6
  171. package/src/device-id.ts +34 -3
  172. package/src/i18n.ts +39 -2
  173. package/src/index.tsx +4 -0
  174. package/src/locales.ts +33 -0
  175. package/src/push/push-notifications.ts +10 -8
  176. package/src/state/widget-store.ts +480 -24
  177. package/src/storage.ts +18 -1
  178. package/src/types.ts +91 -8
  179. package/src/ui/WidgetProvider.tsx +166 -48
  180. package/src/ui/components/AgentUiBlocks.tsx +40 -12
  181. package/src/ui/components/Launcher.tsx +6 -1
  182. package/src/ui/components/Markdown.tsx +34 -3
  183. package/src/ui/components/Post.tsx +943 -0
  184. package/src/ui/components/RatingCard.tsx +6 -2
  185. package/src/ui/hooks/use-widget-insets.ts +0 -1
  186. package/src/ui/screens/chat/ChatView.tsx +77 -42
  187. package/src/ui/screens/chat/ChatView.types.ts +10 -0
  188. package/src/ui/utils/html.ts +10 -2
  189. package/src/ui/utils/native-gradient.ts +0 -1
  190. package/src/ui/utils/post-html.ts +256 -0
  191. package/src/ui/utils/safe-url.ts +15 -0
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.WidgetStore = void 0;
6
+ exports.WidgetStore = exports.POST_THANKS_MS = void 0;
7
7
  var _reactNative = require("react-native");
8
8
  var _client = require("../api/client.js");
9
9
  var _realtime = require("../api/realtime.js");
@@ -24,7 +24,8 @@ var _safeUrl = require("../ui/utils/safe-url.js");
24
24
  * only a subscriber.
25
25
  */
26
26
 
27
- const SDK_VERSION = '@repzo/widget-react-native@1.0.5';
27
+ /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
28
+ const SDK_VERSION = '@repzo/widget-react-native@1.2.0';
28
29
 
29
30
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
30
31
  function sessionPlatform() {
@@ -47,6 +48,12 @@ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
47
48
  const MARK_READ_DEBOUNCE_MS = 1_000;
48
49
  /** Live events held back while a draft's first send is creating its thread. */
49
50
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
51
+ /** How long a post stays up after a reaction, thanking them, before it goes. */
52
+ const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
53
+
54
+ /** Ask a moment after the server's "post stops" time, never waiting longer than this per timer. */
55
+ const POST_RECHECK_SLACK_MS = 1_000;
56
+ const POST_RECHECK_MAX_MS = 6 * 60 * 60 * 1000;
50
57
  /** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
51
58
  const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
52
59
  const sumUnread = map => Object.values(map).reduce((sum, count) => sum + count, 0);
@@ -73,7 +80,9 @@ const INITIAL_STATE = {
73
80
  prefillText: '',
74
81
  outboundMessage: null,
75
82
  visitorProfile: {},
76
- inAppNotification: null
83
+ inAppNotification: null,
84
+ post: null,
85
+ postExpanded: false
77
86
  };
78
87
 
79
88
  /** Everything in state that belongs to the signed-in visitor. */
@@ -93,14 +102,16 @@ const USER_SCOPED_STATE = {
93
102
  prefillText: '',
94
103
  outboundMessage: null,
95
104
  visitorProfile: {},
96
- inAppNotification: null
105
+ inAppNotification: null,
106
+ post: null,
107
+ postExpanded: false
97
108
  };
98
109
  class WidgetStore {
99
110
  client = new _client.WidgetApiClient();
100
111
  events = new _events.EventEmitter();
101
112
  push = new _pushNotifications.PushManager(this.client, conversationId => {
102
113
  void this.openConversation(conversationId);
103
- });
114
+ }, campaignId => this.openPostFromPush(campaignId));
104
115
  state = INITIAL_STATE;
105
116
  subscribers = new Set();
106
117
  realtime = new _realtime.WidgetRealtime();
@@ -111,6 +122,34 @@ class WidgetStore {
111
122
  /** `boot({ inAppNotifications })` — the card over the app for new replies. */
112
123
  inAppNotificationsEnabled = true;
113
124
 
125
+ /** `boot({ posts })` — Send posts over the app. */
126
+ postsEnabled = true;
127
+
128
+ /** Posts whose view was reported this session (the server also dedupes per day). */
129
+ viewedPostIds = new Set();
130
+
131
+ /** Bumped per post load, so an older answer never lands over a newer one. */
132
+ postRequest = 0;
133
+
134
+ /** Bumped when a live answer or a saved reply joins the post, so a load sent before it is retried, not shown. */
135
+ postRevision = 0;
136
+
137
+ /** Conversations whose unread replies include one the post can't show (attachments only): opening the post leaves them unread. */
138
+ postUnshownReplies = new Set();
139
+ postRecheckTimer = null;
140
+
141
+ /** A post push tapped before the session was ready: open it once it is. */
142
+ pendingPostId = null;
143
+
144
+ /** Posts closed with × whose report failed (offline, say): kept down, and the close re-sent when they come back. */
145
+ unsentDismissIds = new Set();
146
+
147
+ /** Bumped when the visitor's posts are reset (logout, wipe, posts off): older post callbacks stand down. */
148
+ postSession = 0;
149
+
150
+ /** A reaction's "thanks" moment: when it ends, the post goes. */
151
+ postThanksTimer = null;
152
+
114
153
  /** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
115
154
  launchCardOffered = false;
116
155
  bootPromise = null;
@@ -266,6 +305,17 @@ class WidgetStore {
266
305
  if (options.inAppNotifications !== undefined) {
267
306
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
268
307
  }
308
+ if (options.posts !== undefined) {
309
+ const wasEnabled = this.postsEnabled;
310
+ this.postsEnabled = options.posts !== false;
311
+ if (!this.postsEnabled) {
312
+ this.resetPost();
313
+ this.pendingPostId = null;
314
+ } else if (!wasEnabled) {
315
+ // A booted session returns early below, so turning posts back on asks here.
316
+ this.loadPost();
317
+ }
318
+ }
269
319
  const sameTarget = this.bootConfig?.baseUrl === baseUrl && this.bootConfig?.channelAccountId === options.channelAccountId;
270
320
  const userKey = options.user ? identityKey(options.user) : null;
271
321
  if (sameTarget && this.bootPromise && (!userKey || userKey === this.bootingUserKey)) {
@@ -465,6 +515,8 @@ class WidgetStore {
465
515
  this.connectRealtime(result.token);
466
516
  this.watchAppState();
467
517
  this.loadOutboundMessage();
518
+ // Audiences and closed posts are per person, so every new session asks again.
519
+ this.loadPost();
468
520
  // Re-runs on login switches too — the token moves to the new identity.
469
521
  void this.push.start();
470
522
  if (this.pendingConversationId) {
@@ -670,6 +722,8 @@ class WidgetStore {
670
722
  this.resetThreadTracking();
671
723
  this.appStateSubscription?.remove();
672
724
  this.appStateSubscription = null;
725
+ this.resetPost();
726
+ this.pendingPostId = null;
673
727
  this.setState({
674
728
  ...INITIAL_STATE
675
729
  });
@@ -702,9 +756,11 @@ class WidgetStore {
702
756
  this.userKeyLoaded = true;
703
757
  this.pendingAgentByConversation = {};
704
758
  this.seenMessageIds = [];
759
+ this.earlyStatuses.clear();
705
760
  this.visitorTypingActive = false;
706
761
  this.lastTypingPingAt = 0;
707
762
  this.launchCardOffered = false;
763
+ this.resetPost();
708
764
  this.setState({
709
765
  ...USER_SCOPED_STATE
710
766
  });
@@ -810,6 +866,8 @@ class WidgetStore {
810
866
  // and the thread on screen so replies sent meanwhile show up.
811
867
  await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
812
868
  await this.refreshConfig();
869
+ // A post may have gone live (or ended) while the app was away.
870
+ if (!this.isStale(epoch)) this.loadPost();
813
871
  }
814
872
 
815
873
  /**
@@ -857,6 +915,8 @@ class WidgetStore {
857
915
  if (this.streamConnectedOnce || this.streamErrored) {
858
916
  void this.refreshConversations();
859
917
  void this.refreshOpenThread();
918
+ // Answers under the post too.
919
+ this.loadPost();
860
920
  }
861
921
  this.streamConnectedOnce = true;
862
922
  this.streamErrored = false;
@@ -979,12 +1039,6 @@ class WidgetStore {
979
1039
  return false;
980
1040
  }
981
1041
 
982
- /**
983
- * A teammate deleted a message: the visitor sees "This message was deleted"
984
- * in its place (they can't delete anything themselves), and the list is
985
- * refreshed so its preview stops showing the text. The id stays in
986
- * `seenMessageIds`, so a replayed `message.new` can't restore the content.
987
- */
988
1042
  /**
989
1043
  * A teammate opened the conversation: the visitor's messages are now Seen.
990
1044
  * The ids are also remembered, because the event can arrive before the send
@@ -994,9 +1048,15 @@ class WidgetStore {
994
1048
  const ids = Array.isArray(data?.messageIds) ? data.messageIds : [];
995
1049
  const status = typeof data?.status === 'string' ? data.status : undefined;
996
1050
  if (!status || ids.length === 0) return;
997
- for (const id of ids) this.earlyStatuses.set(id, status);
998
- if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
999
- this.earlyStatuses.delete(this.earlyStatuses.keys().next().value);
1051
+ for (const id of ids) {
1052
+ // Re-inserted, so a repeated id counts as newest when trimming.
1053
+ this.earlyStatuses.delete(id);
1054
+ this.earlyStatuses.set(id, status);
1055
+ }
1056
+ // One event can carry many ids: trim back to the cap, oldest first.
1057
+ for (const id of this.earlyStatuses.keys()) {
1058
+ if (this.earlyStatuses.size <= SEEN_MESSAGE_LIMIT) break;
1059
+ this.earlyStatuses.delete(id);
1000
1060
  }
1001
1061
  if (!this.isThreadOnScreen(data?.conversationId)) return;
1002
1062
  this.setState(current => ({
@@ -1006,6 +1066,13 @@ class WidgetStore {
1006
1066
  } : message)
1007
1067
  }));
1008
1068
  }
1069
+
1070
+ /**
1071
+ * A teammate deleted a message: the visitor sees "This message was deleted"
1072
+ * in its place (they can't delete anything themselves), and the list is
1073
+ * refreshed so its preview stops showing the text. The id stays in
1074
+ * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1075
+ */
1009
1076
  handleDeletedMessage(data) {
1010
1077
  const messageId = data?.messageId;
1011
1078
  if (!messageId) return;
@@ -1048,6 +1115,17 @@ class WidgetStore {
1048
1115
  // never system rows like "agent joined" (the server counts the same way).
1049
1116
  const isTeamReply = message.messageType === 'message' && message.direction !== 'inbound' && message.senderType !== 'visitor';
1050
1117
  const onScreen = this.isThreadOnScreen(conversationId);
1118
+ // A team answer in the shown post's conversation shows under the post —
1119
+ // and, with the post open in front of them, counts as read there. The
1120
+ // post only shows text: an attachment-only answer stays unread (card,
1121
+ // badge) for the messenger, and so does everything after it until the
1122
+ // conversation is opened there, since reading moves the server cursor past it.
1123
+ const post = this.state.post;
1124
+ const inPost = isTeamReply && !!conversationId && post?.conversationId === conversationId;
1125
+ const answerText = (message.bodyPlain ?? '').trim();
1126
+ if (inPost && !this.state.unreadByConversation[conversationId]) this.postUnshownReplies.delete(conversationId);
1127
+ const readInPost = inPost && !!answerText && this.state.postExpanded && !this.state.isOpen && !this.state.unreadByConversation[conversationId];
1128
+ if (inPost && !answerText) this.postUnshownReplies.add(conversationId);
1051
1129
 
1052
1130
  // Set inside the updater, read after: `setState` runs synchronously.
1053
1131
  let countedAsUnread = false;
@@ -1066,7 +1144,7 @@ class WidgetStore {
1066
1144
  agentTyping: clearsTyping ? false : current.agentTyping
1067
1145
  };
1068
1146
  }
1069
- if (!isTeamReply || !conversationId) return {
1147
+ if (!isTeamReply || !conversationId || readInPost) return {
1070
1148
  conversations
1071
1149
  };
1072
1150
  const unreadByConversation = {
@@ -1099,7 +1177,23 @@ class WidgetStore {
1099
1177
 
1100
1178
  // The visitor is reading it live — move the server cursor too, or the
1101
1179
  // next list/init would badge a reply they already saw.
1102
- if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1180
+ if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1181
+ const current = this.state.post;
1182
+ if (inPost && answerText && current?.id === post?.id) {
1183
+ const answer = {
1184
+ from: 'team',
1185
+ text: answerText,
1186
+ senderName: message.senderName,
1187
+ at: message.createdAt
1188
+ };
1189
+ this.postRevision += 1;
1190
+ this.setState({
1191
+ post: {
1192
+ ...current,
1193
+ thread: [...current.thread, answer]
1194
+ }
1195
+ });
1196
+ }
1103
1197
 
1104
1198
  // Emitted after the state write so a listener reading the unread count sees
1105
1199
  // the new value. Only unread-counting replies fire it, which keeps the event
@@ -1150,7 +1244,7 @@ class WidgetStore {
1150
1244
  body: null,
1151
1245
  bodyPlain: data.bodyPlain || 'Conversation updated',
1152
1246
  attachments: null,
1153
- metadata: data.metadata || null,
1247
+ metadata: this.hydrateMetadata(data.metadata || null),
1154
1248
  status: 'delivered',
1155
1249
  createdAt: data.createdAt || new Date().toISOString()
1156
1250
  };
@@ -1268,6 +1362,8 @@ class WidgetStore {
1268
1362
  });
1269
1363
  this.clearTypingTimer();
1270
1364
  this.track('widget_closed');
1365
+ // A post left open behind the messenger is back in front of them.
1366
+ this.readPostConversation();
1271
1367
  }
1272
1368
  toggle(space) {
1273
1369
  if (this.state.isOpen) this.hide();else this.present(space);
@@ -1560,12 +1656,39 @@ class WidgetStore {
1560
1656
  })).reverse();
1561
1657
  }
1562
1658
 
1563
- /** The "joined" line's avatar can be a server-relative media path. */
1659
+ /**
1660
+ * Media URLs inside metadata (the "joined" line's avatar, product photos in
1661
+ * agent blocks) can be server-relative paths, and are remote content: like
1662
+ * every other media URL they resolve against `baseUrl`, and anything that
1663
+ * isn't http(s) is dropped before it reaches an `<Image>`.
1664
+ */
1564
1665
  hydrateMetadata(metadata) {
1565
- if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
1666
+ if (!metadata) return null;
1667
+ let next = metadata;
1668
+ if (typeof metadata.agentAvatar === 'string') {
1669
+ next = {
1670
+ ...next,
1671
+ agentAvatar: this.client.resolveUrl(metadata.agentAvatar)
1672
+ };
1673
+ }
1674
+ if (Array.isArray(metadata.agentUiBlocks)) {
1675
+ next = {
1676
+ ...next,
1677
+ agentUiBlocks: metadata.agentUiBlocks.map(block => this.hydrateBlockMedia(block))
1678
+ };
1679
+ }
1680
+ return next;
1681
+ }
1682
+ hydrateBlockMedia(block) {
1683
+ const candidate = block;
1684
+ const imageUrl = candidate?.product?.imageUrl;
1685
+ if (candidate?.type !== 'product_service_card' || typeof imageUrl !== 'string') return block;
1566
1686
  return {
1567
- ...metadata,
1568
- agentAvatar: this.client.resolveUrl(metadata.agentAvatar)
1687
+ ...candidate,
1688
+ product: {
1689
+ ...candidate.product,
1690
+ imageUrl: this.client.resolveUrl(imageUrl) ?? undefined
1691
+ }
1569
1692
  };
1570
1693
  }
1571
1694
 
@@ -1802,9 +1925,366 @@ class WidgetStore {
1802
1925
  }).catch(() => {});
1803
1926
  }
1804
1927
 
1928
+ // ─── Send posts ─────────────────────────────────────────────────────────
1929
+
1930
+ /** Ask the server for the post this visitor should see now. */
1931
+ loadPost() {
1932
+ if (!this.postsEnabled || !this.state.booted) return;
1933
+ const session = this.postSession;
1934
+ const request = ++this.postRequest;
1935
+ const revision = this.postRevision;
1936
+ this.client.getPost().then(({
1937
+ post,
1938
+ recheckInMs
1939
+ }) => {
1940
+ if (this.postStale(session) || request !== this.postRequest) return;
1941
+ // An answer or reply joined the post meanwhile, maybe after this was
1942
+ // read: ask again rather than show the post without it.
1943
+ if (revision !== this.postRevision) {
1944
+ this.loadPost();
1945
+ return;
1946
+ }
1947
+ const hydrated = post ? this.hydratePost(post) : null;
1948
+ const pushed = this.pendingPostId;
1949
+ this.pendingPostId = null;
1950
+ this.schedulePostRecheck(recheckInMs, session);
1951
+ if (hydrated && this.unsentDismissIds.has(hydrated.id)) {
1952
+ // Closed already; the server only missed hearing it.
1953
+ this.showPost(null);
1954
+ this.resendPostDismiss(hydrated.id, session);
1955
+ return;
1956
+ }
1957
+ // A full post opens by itself unless they minimized it; a tapped push always opens it.
1958
+ this.showPost(hydrated, !!hydrated && (hydrated.sentAs === 'full' && !hydrated.minimized || hydrated.id === pushed));
1959
+ })
1960
+ // Keep whatever is on screen; the next session or foreground asks again.
1961
+ .catch(() => {});
1962
+ }
1963
+
1964
+ /**
1965
+ * Coerces the server's post into the shape the view relies on: missing
1966
+ * strings become empty, unknown enum values fall back to the plainest
1967
+ * option, and malformed reactions or replies are dropped.
1968
+ */
1969
+ hydratePost(post) {
1970
+ if (!post || typeof post.id !== 'string' || !post.id || typeof post.bodyHtml !== 'string') return null;
1971
+ const text = value => typeof value === 'string' ? value : '';
1972
+ const sender = post.sender && text(post.sender.name) ? post.sender : null;
1973
+ const replies = Array.isArray(post.replies) ? post.replies.filter(reply => typeof reply?.text === 'string' && reply.text.length > 0).map(reply => ({
1974
+ text: reply.text,
1975
+ at: text(reply.at)
1976
+ })) : [];
1977
+ return {
1978
+ id: post.id,
1979
+ bodyHtml: post.bodyHtml,
1980
+ snippet: text(post.snippet),
1981
+ sender: sender ? {
1982
+ name: sender.name,
1983
+ initials: text(sender.initials),
1984
+ // Teammate photos can be server-relative media paths.
1985
+ avatarUrl: this.client.resolveUrl(typeof sender.avatarUrl === 'string' ? sender.avatarUrl : null)
1986
+ } : null,
1987
+ workspaceName: text(post.workspaceName),
1988
+ format: post.format === 'large' ? 'large' : 'small',
1989
+ replyType: post.replyType === 'text' || post.replyType === 'reaction' ? post.replyType : 'none',
1990
+ reactions: Array.isArray(post.reactions) ? post.reactions.filter(emoji => typeof emoji === 'string' && emoji.length > 0) : [],
1991
+ sentAs: post.sentAs === 'badge' || post.sentAs === 'full' ? post.sentAs : 'snippet',
1992
+ reacted: typeof post.reacted === 'string' && post.reacted ? post.reacted : null,
1993
+ // Older Workstation versions don't send it.
1994
+ minimized: post.minimized === true,
1995
+ replies,
1996
+ thread: Array.isArray(post.thread) ? post.thread.filter(message => typeof message?.text === 'string' && message.text.length > 0).map(message => ({
1997
+ from: message.from === 'team' ? 'team' : 'person',
1998
+ text: message.text,
1999
+ senderName: message.from === 'team' ? text(message.senderName) || null : null,
2000
+ at: text(message.at)
2001
+ })) : replies.map(reply => ({
2002
+ from: 'person',
2003
+ text: reply.text,
2004
+ senderName: null,
2005
+ at: reply.at
2006
+ })),
2007
+ conversationId: text(post.conversationId) || null
2008
+ };
2009
+ }
2010
+
2011
+ /**
2012
+ * Puts a post on screen (or takes it down). A different post starts open or
2013
+ * as its card per `expand`; the same one keeps what the visitor left it as,
2014
+ * so a full post put back to its card doesn't pop open on every foreground.
2015
+ */
2016
+ showPost(post, expand = false) {
2017
+ const current = this.state.post;
2018
+ if (current && current.id !== post?.id) this.viewedPostIds.delete(current.id);
2019
+ const samePost = !!post && current?.id === post.id;
2020
+ const expanded = !!post && (samePost ? this.state.postExpanded : expand);
2021
+ this.setState({
2022
+ post,
2023
+ postExpanded: expanded
2024
+ });
2025
+ if (post && expanded && !samePost) this.reportPostOpen(post);
2026
+ this.readPostConversation();
2027
+ }
2028
+
2029
+ /** The post's card or full view is on screen. Reported once each time it's shown. */
2030
+ postDidShow(campaignId) {
2031
+ if (this.state.post?.id !== campaignId || this.viewedPostIds.has(campaignId)) return;
2032
+ this.viewedPostIds.add(campaignId);
2033
+ this.client.sendPostInteraction(campaignId, 'view').catch(() => {});
2034
+ }
2035
+
2036
+ /** The visitor tapped the post's card: open it in full. */
2037
+ expandPost(campaignId) {
2038
+ const post = this.state.post;
2039
+ if (!post || post.id !== campaignId || this.state.postExpanded) return;
2040
+ this.setState({
2041
+ postExpanded: true
2042
+ });
2043
+ this.reportPostOpen(post);
2044
+ this.readPostConversation();
2045
+ }
2046
+
2047
+ /**
2048
+ * The post is open in front of them (and the messenger isn't): the team's
2049
+ * answers under it are read, here and on the server. Not when one of them
2050
+ * is something the post can't show, or the post has fewer answers since
2051
+ * their last reply than are unread (an attachment from before this launch,
2052
+ * say): those wait for the messenger.
2053
+ */
2054
+ readPostConversation() {
2055
+ const {
2056
+ post,
2057
+ postExpanded,
2058
+ isOpen,
2059
+ unreadByConversation
2060
+ } = this.state;
2061
+ const conversationId = post?.conversationId;
2062
+ const unread = conversationId ? unreadByConversation[conversationId] ?? 0 : 0;
2063
+ if (!post || !conversationId || !postExpanded || isOpen || unread === 0) return;
2064
+ if (this.postUnshownReplies.has(conversationId)) return;
2065
+ let shown = 0;
2066
+ for (let index = post.thread.length - 1; index >= 0 && post.thread[index]?.from === 'team'; index -= 1) shown += 1;
2067
+ if (shown < unread) return;
2068
+ this.setState({
2069
+ unreadByConversation: this.clearUnreadFor(conversationId)
2070
+ });
2071
+ this.events.emit(_events.RepzoWidgetEvents.ConversationDidRead, {
2072
+ conversationId
2073
+ });
2074
+ this.scheduleMarkRead(conversationId);
2075
+ }
2076
+
2077
+ /** Back to its pill or card for now, without telling the server (see PostOverlay's onCollapse). */
2078
+ collapsePost(campaignId) {
2079
+ if (this.state.post?.id !== campaignId || !this.state.postExpanded) return;
2080
+ this.setState({
2081
+ postExpanded: false
2082
+ });
2083
+ }
2084
+
2085
+ /**
2086
+ * "Not now" — the ⌄ button, a swipe down, or Android's back: the post goes
2087
+ * back to its badge (or snippet card) and stays there, on later launches
2088
+ * too, instead of opening by itself again. Unlike ×, it can be opened again.
2089
+ */
2090
+ minimizePost(campaignId) {
2091
+ const post = this.state.post;
2092
+ if (!post || post.id !== campaignId || !this.state.postExpanded) return;
2093
+ this.setState({
2094
+ post: {
2095
+ ...post,
2096
+ minimized: true
2097
+ },
2098
+ postExpanded: false
2099
+ });
2100
+ if (!post.minimized) this.client.sendPostInteraction(campaignId, 'minimize').catch(() => {});
2101
+ }
2102
+ reportPostOpen(post) {
2103
+ // "Opened" is opening it from its card; a full post is opened by showing it.
2104
+ if (post.sentAs === 'full') return;
2105
+ this.client.sendPostInteraction(post.id, 'open').catch(() => {});
2106
+ }
2107
+
2108
+ /**
2109
+ * The visitor tapped a link, reacted, replied to or closed the post. Closing
2110
+ * takes it down at once and loads the next one; a reaction shows as picked
2111
+ * right away. A reply resolves with what was saved (shown under the post)
2112
+ * and rejects when it wasn't, so the reply box can keep the text.
2113
+ */
2114
+ async recordPostInteraction(campaignId, kind, value) {
2115
+ const post = this.state.post;
2116
+ if (!post || post.id !== campaignId) return undefined;
2117
+ const session = this.postSession;
2118
+ if (kind === 'dismiss') {
2119
+ // A load already in flight would put the closed post back.
2120
+ this.postRequest += 1;
2121
+ this.showPost(null);
2122
+ }
2123
+ if (kind === 'reaction' && value) this.setState({
2124
+ post: {
2125
+ ...post,
2126
+ reacted: value
2127
+ }
2128
+ });
2129
+ const sent = this.client.sendPostInteraction(campaignId, kind, value);
2130
+ // A reaction answers the post: it thanks them for a moment, then goes
2131
+ // (another tap in the meantime changes their pick and restarts it).
2132
+ if (kind === 'reaction' && value) this.startPostThanks(campaignId, sent, session);
2133
+ let result;
2134
+ try {
2135
+ result = await sent;
2136
+ } catch (error) {
2137
+ if (kind === 'reply') throw error;
2138
+ if (this.postStale(session)) return undefined;
2139
+ // × closes it for good, so it stays down until the server hears it.
2140
+ if (kind === 'dismiss') this.unsentDismissIds.add(campaignId);
2141
+ if (kind === 'reaction') {
2142
+ const current = this.state.post;
2143
+ if (current?.id === campaignId && current.reacted === value) {
2144
+ this.setState({
2145
+ post: {
2146
+ ...current,
2147
+ reacted: post.reacted
2148
+ }
2149
+ });
2150
+ // Not saved: it stays, as it was, so they can try again — unless an
2151
+ // earlier pick was saved, which already answered it: that one leaves.
2152
+ if (post.reacted) this.startPostThanks(campaignId, Promise.resolve(), session);else this.clearPostThanks();
2153
+ }
2154
+ }
2155
+ return undefined;
2156
+ }
2157
+ if (this.postStale(session)) return undefined;
2158
+ if (kind === 'reply') {
2159
+ // Falls back to what was typed when the response has no usable reply.
2160
+ const saved = result?.reply;
2161
+ const reply = typeof saved?.text === 'string' && saved.text ? {
2162
+ text: saved.text,
2163
+ at: typeof saved.at === 'string' ? saved.at : new Date().toISOString()
2164
+ } : {
2165
+ text: value ?? '',
2166
+ at: new Date().toISOString()
2167
+ };
2168
+ const current = this.state.post;
2169
+ if (current?.id === campaignId) {
2170
+ // Their first reply starts the post's conversation: answers there join the thread from now on.
2171
+ const conversationId = typeof result?.conversationId === 'string' && result.conversationId ? result.conversationId : current.conversationId;
2172
+ this.postRevision += 1;
2173
+ this.setState({
2174
+ post: {
2175
+ ...current,
2176
+ replies: [...current.replies, reply],
2177
+ thread: [...current.thread, {
2178
+ from: 'person',
2179
+ text: reply.text,
2180
+ senderName: null,
2181
+ at: reply.at
2182
+ }],
2183
+ conversationId
2184
+ }
2185
+ });
2186
+ }
2187
+ // The reply went to the post's conversation: show it in the Messenger's list.
2188
+ void this.refreshConversations();
2189
+ return reply;
2190
+ }
2191
+ // A reaction's post leaves when its thanks ends (see startPostThanks).
2192
+ if (result?.hidden && kind !== 'reaction') {
2193
+ if (this.state.post?.id === campaignId) this.showPost(null);
2194
+ this.loadPost();
2195
+ }
2196
+ return undefined;
2197
+ }
2198
+ startPostThanks(campaignId, sent, session) {
2199
+ this.clearPostThanks();
2200
+ this.postThanksTimer = setTimeout(() => {
2201
+ this.postThanksTimer = null;
2202
+ if (this.postStale(session) || this.state.post?.id !== campaignId) return;
2203
+ this.postRequest += 1;
2204
+ this.showPost(null);
2205
+ // Ask for the next one once the server has the reaction, so it doesn't send this one back.
2206
+ sent.then(() => {
2207
+ if (!this.postStale(session)) this.loadPost();
2208
+ }, () => {
2209
+ if (!this.postStale(session)) this.loadPost();
2210
+ });
2211
+ }, POST_THANKS_MS);
2212
+ }
2213
+ clearPostThanks() {
2214
+ if (this.postThanksTimer) clearTimeout(this.postThanksTimer);
2215
+ this.postThanksTimer = null;
2216
+ }
2217
+ resendPostDismiss(campaignId, session) {
2218
+ this.client.sendPostInteraction(campaignId, 'dismiss').then(() => {
2219
+ if (this.postStale(session)) return;
2220
+ this.unsentDismissIds.delete(campaignId);
2221
+ this.loadPost();
2222
+ })
2223
+ // Still offline: it stays down and the next load tries again.
2224
+ .catch(() => {});
2225
+ }
2226
+
2227
+ /**
2228
+ * A post push was tapped: show that post, open in full, as soon as the
2229
+ * session is ready. The messenger steps aside for it, the way a reply push
2230
+ * opens its conversation.
2231
+ */
2232
+ openPostFromPush(campaignId) {
2233
+ if (!this.postsEnabled) return;
2234
+ if (this.state.isOpen) this.hide();
2235
+ this.pendingPostId = campaignId;
2236
+ if (this.state.post?.id === campaignId) {
2237
+ this.pendingPostId = null;
2238
+ this.expandPost(campaignId);
2239
+ return;
2240
+ }
2241
+ if (this.state.booted) this.loadPost();
2242
+ }
2243
+ clearPostRecheck() {
2244
+ if (this.postRecheckTimer) clearTimeout(this.postRecheckTimer);
2245
+ this.postRecheckTimer = null;
2246
+ }
2247
+
2248
+ /** The server knows when the shown post stops; ask again then, so it leaves on time. */
2249
+ schedulePostRecheck(recheckInMs, session) {
2250
+ this.clearPostRecheck();
2251
+ if (typeof recheckInMs !== 'number' || !Number.isFinite(recheckInMs)) return;
2252
+ const delay = Math.min(Math.max(recheckInMs, 0) + POST_RECHECK_SLACK_MS, POST_RECHECK_MAX_MS);
2253
+ this.postRecheckTimer = setTimeout(() => {
2254
+ this.postRecheckTimer = null;
2255
+ if (!this.postStale(session)) this.loadPost();
2256
+ }, delay);
2257
+ }
2258
+
2259
+ /**
2260
+ * Post callbacks check this, not the identity epoch: a same-user login or
2261
+ * an anonymous re-login bumps the epoch but keeps the session (and the post),
2262
+ * and would otherwise drop the post's load and strand its timers.
2263
+ */
2264
+ postStale(session) {
2265
+ return session !== this.postSession;
2266
+ }
2267
+
2268
+ /** The visitor changed or posts were turned off: drop it and any pending load. */
2269
+ resetPost() {
2270
+ this.postSession += 1;
2271
+ this.clearPostRecheck();
2272
+ this.clearPostThanks();
2273
+ this.postRequest += 1;
2274
+ this.viewedPostIds.clear();
2275
+ this.unsentDismissIds.clear();
2276
+ this.postUnshownReplies.clear();
2277
+ if (this.state.post || this.state.postExpanded) this.setState({
2278
+ post: null,
2279
+ postExpanded: false
2280
+ });
2281
+ }
2282
+
1805
2283
  // ─── Teardown (tests / hot reload) ──────────────────────────────────────
1806
2284
 
1807
2285
  destroy() {
2286
+ this.clearPostRecheck();
2287
+ this.clearPostThanks();
1808
2288
  this.realtime.close();
1809
2289
  this.clearTypingTimer();
1810
2290
  this.resetThreadTracking();
@@ -1832,11 +2312,6 @@ function isLocalMessage(message) {
1832
2312
  return message.id.startsWith('temp-');
1833
2313
  }
1834
2314
 
1835
- /**
1836
- * A sent visitor message keeps its local previews (a `file://` URI, or no URL
1837
- * at all) until the server copy shows up — the stream echo or a history page.
1838
- * Its attachments, the only ones that can be opened, then replace them.
1839
- */
1840
2315
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
1841
2316
  function adoptServerStatuses(messages, server) {
1842
2317
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -1878,6 +2353,12 @@ function adoptServerDeletions(messages, server) {
1878
2353
  });
1879
2354
  return changed ? next : messages;
1880
2355
  }
2356
+
2357
+ /**
2358
+ * A sent visitor message keeps its local previews (a `file://` URI, or no URL
2359
+ * at all) until the server copy shows up — the stream echo or a history page.
2360
+ * Its attachments, the only ones that can be opened, then replace them.
2361
+ */
1881
2362
  function adoptServerAttachments(messages, server) {
1882
2363
  const serverCopies = new Map(server.filter(message => message.attachments?.length).map(message => [message.id, message]));
1883
2364
  if (serverCopies.size === 0) return messages;