@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
@@ -20,7 +20,9 @@ import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER
20
20
  import { setAttachmentOptions } from "../attachments/pickers.js";
21
21
  import { DEFAULT_BASE_URL } from "../types.js";
22
22
  import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
23
- const SDK_VERSION = '@repzo/widget-react-native@1.0.5';
23
+
24
+ /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
25
+ const SDK_VERSION = '@repzo/widget-react-native@1.2.0';
24
26
 
25
27
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
26
28
  function sessionPlatform() {
@@ -43,6 +45,12 @@ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
43
45
  const MARK_READ_DEBOUNCE_MS = 1_000;
44
46
  /** Live events held back while a draft's first send is creating its thread. */
45
47
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
48
+ /** How long a post stays up after a reaction, thanking them, before it goes. */
49
+ export const POST_THANKS_MS = 1600;
50
+
51
+ /** Ask a moment after the server's "post stops" time, never waiting longer than this per timer. */
52
+ const POST_RECHECK_SLACK_MS = 1_000;
53
+ const POST_RECHECK_MAX_MS = 6 * 60 * 60 * 1000;
46
54
  /** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
47
55
  const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
48
56
  const sumUnread = map => Object.values(map).reduce((sum, count) => sum + count, 0);
@@ -69,7 +77,9 @@ const INITIAL_STATE = {
69
77
  prefillText: '',
70
78
  outboundMessage: null,
71
79
  visitorProfile: {},
72
- inAppNotification: null
80
+ inAppNotification: null,
81
+ post: null,
82
+ postExpanded: false
73
83
  };
74
84
 
75
85
  /** Everything in state that belongs to the signed-in visitor. */
@@ -89,14 +99,16 @@ const USER_SCOPED_STATE = {
89
99
  prefillText: '',
90
100
  outboundMessage: null,
91
101
  visitorProfile: {},
92
- inAppNotification: null
102
+ inAppNotification: null,
103
+ post: null,
104
+ postExpanded: false
93
105
  };
94
106
  export class WidgetStore {
95
107
  client = new WidgetApiClient();
96
108
  events = new EventEmitter();
97
109
  push = new PushManager(this.client, conversationId => {
98
110
  void this.openConversation(conversationId);
99
- });
111
+ }, campaignId => this.openPostFromPush(campaignId));
100
112
  state = INITIAL_STATE;
101
113
  subscribers = new Set();
102
114
  realtime = new WidgetRealtime();
@@ -107,6 +119,34 @@ export class WidgetStore {
107
119
  /** `boot({ inAppNotifications })` — the card over the app for new replies. */
108
120
  inAppNotificationsEnabled = true;
109
121
 
122
+ /** `boot({ posts })` — Send posts over the app. */
123
+ postsEnabled = true;
124
+
125
+ /** Posts whose view was reported this session (the server also dedupes per day). */
126
+ viewedPostIds = new Set();
127
+
128
+ /** Bumped per post load, so an older answer never lands over a newer one. */
129
+ postRequest = 0;
130
+
131
+ /** Bumped when a live answer or a saved reply joins the post, so a load sent before it is retried, not shown. */
132
+ postRevision = 0;
133
+
134
+ /** Conversations whose unread replies include one the post can't show (attachments only): opening the post leaves them unread. */
135
+ postUnshownReplies = new Set();
136
+ postRecheckTimer = null;
137
+
138
+ /** A post push tapped before the session was ready: open it once it is. */
139
+ pendingPostId = null;
140
+
141
+ /** Posts closed with × whose report failed (offline, say): kept down, and the close re-sent when they come back. */
142
+ unsentDismissIds = new Set();
143
+
144
+ /** Bumped when the visitor's posts are reset (logout, wipe, posts off): older post callbacks stand down. */
145
+ postSession = 0;
146
+
147
+ /** A reaction's "thanks" moment: when it ends, the post goes. */
148
+ postThanksTimer = null;
149
+
110
150
  /** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
111
151
  launchCardOffered = false;
112
152
  bootPromise = null;
@@ -262,6 +302,17 @@ export class WidgetStore {
262
302
  if (options.inAppNotifications !== undefined) {
263
303
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
264
304
  }
305
+ if (options.posts !== undefined) {
306
+ const wasEnabled = this.postsEnabled;
307
+ this.postsEnabled = options.posts !== false;
308
+ if (!this.postsEnabled) {
309
+ this.resetPost();
310
+ this.pendingPostId = null;
311
+ } else if (!wasEnabled) {
312
+ // A booted session returns early below, so turning posts back on asks here.
313
+ this.loadPost();
314
+ }
315
+ }
265
316
  const sameTarget = this.bootConfig?.baseUrl === baseUrl && this.bootConfig?.channelAccountId === options.channelAccountId;
266
317
  const userKey = options.user ? identityKey(options.user) : null;
267
318
  if (sameTarget && this.bootPromise && (!userKey || userKey === this.bootingUserKey)) {
@@ -461,6 +512,8 @@ export class WidgetStore {
461
512
  this.connectRealtime(result.token);
462
513
  this.watchAppState();
463
514
  this.loadOutboundMessage();
515
+ // Audiences and closed posts are per person, so every new session asks again.
516
+ this.loadPost();
464
517
  // Re-runs on login switches too — the token moves to the new identity.
465
518
  void this.push.start();
466
519
  if (this.pendingConversationId) {
@@ -666,6 +719,8 @@ export class WidgetStore {
666
719
  this.resetThreadTracking();
667
720
  this.appStateSubscription?.remove();
668
721
  this.appStateSubscription = null;
722
+ this.resetPost();
723
+ this.pendingPostId = null;
669
724
  this.setState({
670
725
  ...INITIAL_STATE
671
726
  });
@@ -698,9 +753,11 @@ export class WidgetStore {
698
753
  this.userKeyLoaded = true;
699
754
  this.pendingAgentByConversation = {};
700
755
  this.seenMessageIds = [];
756
+ this.earlyStatuses.clear();
701
757
  this.visitorTypingActive = false;
702
758
  this.lastTypingPingAt = 0;
703
759
  this.launchCardOffered = false;
760
+ this.resetPost();
704
761
  this.setState({
705
762
  ...USER_SCOPED_STATE
706
763
  });
@@ -806,6 +863,8 @@ export class WidgetStore {
806
863
  // and the thread on screen so replies sent meanwhile show up.
807
864
  await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
808
865
  await this.refreshConfig();
866
+ // A post may have gone live (or ended) while the app was away.
867
+ if (!this.isStale(epoch)) this.loadPost();
809
868
  }
810
869
 
811
870
  /**
@@ -853,6 +912,8 @@ export class WidgetStore {
853
912
  if (this.streamConnectedOnce || this.streamErrored) {
854
913
  void this.refreshConversations();
855
914
  void this.refreshOpenThread();
915
+ // Answers under the post too.
916
+ this.loadPost();
856
917
  }
857
918
  this.streamConnectedOnce = true;
858
919
  this.streamErrored = false;
@@ -975,12 +1036,6 @@ export class WidgetStore {
975
1036
  return false;
976
1037
  }
977
1038
 
978
- /**
979
- * A teammate deleted a message: the visitor sees "This message was deleted"
980
- * in its place (they can't delete anything themselves), and the list is
981
- * refreshed so its preview stops showing the text. The id stays in
982
- * `seenMessageIds`, so a replayed `message.new` can't restore the content.
983
- */
984
1039
  /**
985
1040
  * A teammate opened the conversation: the visitor's messages are now Seen.
986
1041
  * The ids are also remembered, because the event can arrive before the send
@@ -990,9 +1045,15 @@ export class WidgetStore {
990
1045
  const ids = Array.isArray(data?.messageIds) ? data.messageIds : [];
991
1046
  const status = typeof data?.status === 'string' ? data.status : undefined;
992
1047
  if (!status || ids.length === 0) return;
993
- for (const id of ids) this.earlyStatuses.set(id, status);
994
- if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
995
- this.earlyStatuses.delete(this.earlyStatuses.keys().next().value);
1048
+ for (const id of ids) {
1049
+ // Re-inserted, so a repeated id counts as newest when trimming.
1050
+ this.earlyStatuses.delete(id);
1051
+ this.earlyStatuses.set(id, status);
1052
+ }
1053
+ // One event can carry many ids: trim back to the cap, oldest first.
1054
+ for (const id of this.earlyStatuses.keys()) {
1055
+ if (this.earlyStatuses.size <= SEEN_MESSAGE_LIMIT) break;
1056
+ this.earlyStatuses.delete(id);
996
1057
  }
997
1058
  if (!this.isThreadOnScreen(data?.conversationId)) return;
998
1059
  this.setState(current => ({
@@ -1002,6 +1063,13 @@ export class WidgetStore {
1002
1063
  } : message)
1003
1064
  }));
1004
1065
  }
1066
+
1067
+ /**
1068
+ * A teammate deleted a message: the visitor sees "This message was deleted"
1069
+ * in its place (they can't delete anything themselves), and the list is
1070
+ * refreshed so its preview stops showing the text. The id stays in
1071
+ * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1072
+ */
1005
1073
  handleDeletedMessage(data) {
1006
1074
  const messageId = data?.messageId;
1007
1075
  if (!messageId) return;
@@ -1044,6 +1112,17 @@ export class WidgetStore {
1044
1112
  // never system rows like "agent joined" (the server counts the same way).
1045
1113
  const isTeamReply = message.messageType === 'message' && message.direction !== 'inbound' && message.senderType !== 'visitor';
1046
1114
  const onScreen = this.isThreadOnScreen(conversationId);
1115
+ // A team answer in the shown post's conversation shows under the post —
1116
+ // and, with the post open in front of them, counts as read there. The
1117
+ // post only shows text: an attachment-only answer stays unread (card,
1118
+ // badge) for the messenger, and so does everything after it until the
1119
+ // conversation is opened there, since reading moves the server cursor past it.
1120
+ const post = this.state.post;
1121
+ const inPost = isTeamReply && !!conversationId && post?.conversationId === conversationId;
1122
+ const answerText = (message.bodyPlain ?? '').trim();
1123
+ if (inPost && !this.state.unreadByConversation[conversationId]) this.postUnshownReplies.delete(conversationId);
1124
+ const readInPost = inPost && !!answerText && this.state.postExpanded && !this.state.isOpen && !this.state.unreadByConversation[conversationId];
1125
+ if (inPost && !answerText) this.postUnshownReplies.add(conversationId);
1047
1126
 
1048
1127
  // Set inside the updater, read after: `setState` runs synchronously.
1049
1128
  let countedAsUnread = false;
@@ -1062,7 +1141,7 @@ export class WidgetStore {
1062
1141
  agentTyping: clearsTyping ? false : current.agentTyping
1063
1142
  };
1064
1143
  }
1065
- if (!isTeamReply || !conversationId) return {
1144
+ if (!isTeamReply || !conversationId || readInPost) return {
1066
1145
  conversations
1067
1146
  };
1068
1147
  const unreadByConversation = {
@@ -1095,7 +1174,23 @@ export class WidgetStore {
1095
1174
 
1096
1175
  // The visitor is reading it live — move the server cursor too, or the
1097
1176
  // next list/init would badge a reply they already saw.
1098
- if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1177
+ if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1178
+ const current = this.state.post;
1179
+ if (inPost && answerText && current?.id === post?.id) {
1180
+ const answer = {
1181
+ from: 'team',
1182
+ text: answerText,
1183
+ senderName: message.senderName,
1184
+ at: message.createdAt
1185
+ };
1186
+ this.postRevision += 1;
1187
+ this.setState({
1188
+ post: {
1189
+ ...current,
1190
+ thread: [...current.thread, answer]
1191
+ }
1192
+ });
1193
+ }
1099
1194
 
1100
1195
  // Emitted after the state write so a listener reading the unread count sees
1101
1196
  // the new value. Only unread-counting replies fire it, which keeps the event
@@ -1146,7 +1241,7 @@ export class WidgetStore {
1146
1241
  body: null,
1147
1242
  bodyPlain: data.bodyPlain || 'Conversation updated',
1148
1243
  attachments: null,
1149
- metadata: data.metadata || null,
1244
+ metadata: this.hydrateMetadata(data.metadata || null),
1150
1245
  status: 'delivered',
1151
1246
  createdAt: data.createdAt || new Date().toISOString()
1152
1247
  };
@@ -1264,6 +1359,8 @@ export class WidgetStore {
1264
1359
  });
1265
1360
  this.clearTypingTimer();
1266
1361
  this.track('widget_closed');
1362
+ // A post left open behind the messenger is back in front of them.
1363
+ this.readPostConversation();
1267
1364
  }
1268
1365
  toggle(space) {
1269
1366
  if (this.state.isOpen) this.hide();else this.present(space);
@@ -1556,12 +1653,39 @@ export class WidgetStore {
1556
1653
  })).reverse();
1557
1654
  }
1558
1655
 
1559
- /** The "joined" line's avatar can be a server-relative media path. */
1656
+ /**
1657
+ * Media URLs inside metadata (the "joined" line's avatar, product photos in
1658
+ * agent blocks) can be server-relative paths, and are remote content: like
1659
+ * every other media URL they resolve against `baseUrl`, and anything that
1660
+ * isn't http(s) is dropped before it reaches an `<Image>`.
1661
+ */
1560
1662
  hydrateMetadata(metadata) {
1561
- if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
1663
+ if (!metadata) return null;
1664
+ let next = metadata;
1665
+ if (typeof metadata.agentAvatar === 'string') {
1666
+ next = {
1667
+ ...next,
1668
+ agentAvatar: this.client.resolveUrl(metadata.agentAvatar)
1669
+ };
1670
+ }
1671
+ if (Array.isArray(metadata.agentUiBlocks)) {
1672
+ next = {
1673
+ ...next,
1674
+ agentUiBlocks: metadata.agentUiBlocks.map(block => this.hydrateBlockMedia(block))
1675
+ };
1676
+ }
1677
+ return next;
1678
+ }
1679
+ hydrateBlockMedia(block) {
1680
+ const candidate = block;
1681
+ const imageUrl = candidate?.product?.imageUrl;
1682
+ if (candidate?.type !== 'product_service_card' || typeof imageUrl !== 'string') return block;
1562
1683
  return {
1563
- ...metadata,
1564
- agentAvatar: this.client.resolveUrl(metadata.agentAvatar)
1684
+ ...candidate,
1685
+ product: {
1686
+ ...candidate.product,
1687
+ imageUrl: this.client.resolveUrl(imageUrl) ?? undefined
1688
+ }
1565
1689
  };
1566
1690
  }
1567
1691
 
@@ -1798,9 +1922,366 @@ export class WidgetStore {
1798
1922
  }).catch(() => {});
1799
1923
  }
1800
1924
 
1925
+ // ─── Send posts ─────────────────────────────────────────────────────────
1926
+
1927
+ /** Ask the server for the post this visitor should see now. */
1928
+ loadPost() {
1929
+ if (!this.postsEnabled || !this.state.booted) return;
1930
+ const session = this.postSession;
1931
+ const request = ++this.postRequest;
1932
+ const revision = this.postRevision;
1933
+ this.client.getPost().then(({
1934
+ post,
1935
+ recheckInMs
1936
+ }) => {
1937
+ if (this.postStale(session) || request !== this.postRequest) return;
1938
+ // An answer or reply joined the post meanwhile, maybe after this was
1939
+ // read: ask again rather than show the post without it.
1940
+ if (revision !== this.postRevision) {
1941
+ this.loadPost();
1942
+ return;
1943
+ }
1944
+ const hydrated = post ? this.hydratePost(post) : null;
1945
+ const pushed = this.pendingPostId;
1946
+ this.pendingPostId = null;
1947
+ this.schedulePostRecheck(recheckInMs, session);
1948
+ if (hydrated && this.unsentDismissIds.has(hydrated.id)) {
1949
+ // Closed already; the server only missed hearing it.
1950
+ this.showPost(null);
1951
+ this.resendPostDismiss(hydrated.id, session);
1952
+ return;
1953
+ }
1954
+ // A full post opens by itself unless they minimized it; a tapped push always opens it.
1955
+ this.showPost(hydrated, !!hydrated && (hydrated.sentAs === 'full' && !hydrated.minimized || hydrated.id === pushed));
1956
+ })
1957
+ // Keep whatever is on screen; the next session or foreground asks again.
1958
+ .catch(() => {});
1959
+ }
1960
+
1961
+ /**
1962
+ * Coerces the server's post into the shape the view relies on: missing
1963
+ * strings become empty, unknown enum values fall back to the plainest
1964
+ * option, and malformed reactions or replies are dropped.
1965
+ */
1966
+ hydratePost(post) {
1967
+ if (!post || typeof post.id !== 'string' || !post.id || typeof post.bodyHtml !== 'string') return null;
1968
+ const text = value => typeof value === 'string' ? value : '';
1969
+ const sender = post.sender && text(post.sender.name) ? post.sender : null;
1970
+ const replies = Array.isArray(post.replies) ? post.replies.filter(reply => typeof reply?.text === 'string' && reply.text.length > 0).map(reply => ({
1971
+ text: reply.text,
1972
+ at: text(reply.at)
1973
+ })) : [];
1974
+ return {
1975
+ id: post.id,
1976
+ bodyHtml: post.bodyHtml,
1977
+ snippet: text(post.snippet),
1978
+ sender: sender ? {
1979
+ name: sender.name,
1980
+ initials: text(sender.initials),
1981
+ // Teammate photos can be server-relative media paths.
1982
+ avatarUrl: this.client.resolveUrl(typeof sender.avatarUrl === 'string' ? sender.avatarUrl : null)
1983
+ } : null,
1984
+ workspaceName: text(post.workspaceName),
1985
+ format: post.format === 'large' ? 'large' : 'small',
1986
+ replyType: post.replyType === 'text' || post.replyType === 'reaction' ? post.replyType : 'none',
1987
+ reactions: Array.isArray(post.reactions) ? post.reactions.filter(emoji => typeof emoji === 'string' && emoji.length > 0) : [],
1988
+ sentAs: post.sentAs === 'badge' || post.sentAs === 'full' ? post.sentAs : 'snippet',
1989
+ reacted: typeof post.reacted === 'string' && post.reacted ? post.reacted : null,
1990
+ // Older Workstation versions don't send it.
1991
+ minimized: post.minimized === true,
1992
+ replies,
1993
+ thread: Array.isArray(post.thread) ? post.thread.filter(message => typeof message?.text === 'string' && message.text.length > 0).map(message => ({
1994
+ from: message.from === 'team' ? 'team' : 'person',
1995
+ text: message.text,
1996
+ senderName: message.from === 'team' ? text(message.senderName) || null : null,
1997
+ at: text(message.at)
1998
+ })) : replies.map(reply => ({
1999
+ from: 'person',
2000
+ text: reply.text,
2001
+ senderName: null,
2002
+ at: reply.at
2003
+ })),
2004
+ conversationId: text(post.conversationId) || null
2005
+ };
2006
+ }
2007
+
2008
+ /**
2009
+ * Puts a post on screen (or takes it down). A different post starts open or
2010
+ * as its card per `expand`; the same one keeps what the visitor left it as,
2011
+ * so a full post put back to its card doesn't pop open on every foreground.
2012
+ */
2013
+ showPost(post, expand = false) {
2014
+ const current = this.state.post;
2015
+ if (current && current.id !== post?.id) this.viewedPostIds.delete(current.id);
2016
+ const samePost = !!post && current?.id === post.id;
2017
+ const expanded = !!post && (samePost ? this.state.postExpanded : expand);
2018
+ this.setState({
2019
+ post,
2020
+ postExpanded: expanded
2021
+ });
2022
+ if (post && expanded && !samePost) this.reportPostOpen(post);
2023
+ this.readPostConversation();
2024
+ }
2025
+
2026
+ /** The post's card or full view is on screen. Reported once each time it's shown. */
2027
+ postDidShow(campaignId) {
2028
+ if (this.state.post?.id !== campaignId || this.viewedPostIds.has(campaignId)) return;
2029
+ this.viewedPostIds.add(campaignId);
2030
+ this.client.sendPostInteraction(campaignId, 'view').catch(() => {});
2031
+ }
2032
+
2033
+ /** The visitor tapped the post's card: open it in full. */
2034
+ expandPost(campaignId) {
2035
+ const post = this.state.post;
2036
+ if (!post || post.id !== campaignId || this.state.postExpanded) return;
2037
+ this.setState({
2038
+ postExpanded: true
2039
+ });
2040
+ this.reportPostOpen(post);
2041
+ this.readPostConversation();
2042
+ }
2043
+
2044
+ /**
2045
+ * The post is open in front of them (and the messenger isn't): the team's
2046
+ * answers under it are read, here and on the server. Not when one of them
2047
+ * is something the post can't show, or the post has fewer answers since
2048
+ * their last reply than are unread (an attachment from before this launch,
2049
+ * say): those wait for the messenger.
2050
+ */
2051
+ readPostConversation() {
2052
+ const {
2053
+ post,
2054
+ postExpanded,
2055
+ isOpen,
2056
+ unreadByConversation
2057
+ } = this.state;
2058
+ const conversationId = post?.conversationId;
2059
+ const unread = conversationId ? unreadByConversation[conversationId] ?? 0 : 0;
2060
+ if (!post || !conversationId || !postExpanded || isOpen || unread === 0) return;
2061
+ if (this.postUnshownReplies.has(conversationId)) return;
2062
+ let shown = 0;
2063
+ for (let index = post.thread.length - 1; index >= 0 && post.thread[index]?.from === 'team'; index -= 1) shown += 1;
2064
+ if (shown < unread) return;
2065
+ this.setState({
2066
+ unreadByConversation: this.clearUnreadFor(conversationId)
2067
+ });
2068
+ this.events.emit(RepzoWidgetEvents.ConversationDidRead, {
2069
+ conversationId
2070
+ });
2071
+ this.scheduleMarkRead(conversationId);
2072
+ }
2073
+
2074
+ /** Back to its pill or card for now, without telling the server (see PostOverlay's onCollapse). */
2075
+ collapsePost(campaignId) {
2076
+ if (this.state.post?.id !== campaignId || !this.state.postExpanded) return;
2077
+ this.setState({
2078
+ postExpanded: false
2079
+ });
2080
+ }
2081
+
2082
+ /**
2083
+ * "Not now" — the ⌄ button, a swipe down, or Android's back: the post goes
2084
+ * back to its badge (or snippet card) and stays there, on later launches
2085
+ * too, instead of opening by itself again. Unlike ×, it can be opened again.
2086
+ */
2087
+ minimizePost(campaignId) {
2088
+ const post = this.state.post;
2089
+ if (!post || post.id !== campaignId || !this.state.postExpanded) return;
2090
+ this.setState({
2091
+ post: {
2092
+ ...post,
2093
+ minimized: true
2094
+ },
2095
+ postExpanded: false
2096
+ });
2097
+ if (!post.minimized) this.client.sendPostInteraction(campaignId, 'minimize').catch(() => {});
2098
+ }
2099
+ reportPostOpen(post) {
2100
+ // "Opened" is opening it from its card; a full post is opened by showing it.
2101
+ if (post.sentAs === 'full') return;
2102
+ this.client.sendPostInteraction(post.id, 'open').catch(() => {});
2103
+ }
2104
+
2105
+ /**
2106
+ * The visitor tapped a link, reacted, replied to or closed the post. Closing
2107
+ * takes it down at once and loads the next one; a reaction shows as picked
2108
+ * right away. A reply resolves with what was saved (shown under the post)
2109
+ * and rejects when it wasn't, so the reply box can keep the text.
2110
+ */
2111
+ async recordPostInteraction(campaignId, kind, value) {
2112
+ const post = this.state.post;
2113
+ if (!post || post.id !== campaignId) return undefined;
2114
+ const session = this.postSession;
2115
+ if (kind === 'dismiss') {
2116
+ // A load already in flight would put the closed post back.
2117
+ this.postRequest += 1;
2118
+ this.showPost(null);
2119
+ }
2120
+ if (kind === 'reaction' && value) this.setState({
2121
+ post: {
2122
+ ...post,
2123
+ reacted: value
2124
+ }
2125
+ });
2126
+ const sent = this.client.sendPostInteraction(campaignId, kind, value);
2127
+ // A reaction answers the post: it thanks them for a moment, then goes
2128
+ // (another tap in the meantime changes their pick and restarts it).
2129
+ if (kind === 'reaction' && value) this.startPostThanks(campaignId, sent, session);
2130
+ let result;
2131
+ try {
2132
+ result = await sent;
2133
+ } catch (error) {
2134
+ if (kind === 'reply') throw error;
2135
+ if (this.postStale(session)) return undefined;
2136
+ // × closes it for good, so it stays down until the server hears it.
2137
+ if (kind === 'dismiss') this.unsentDismissIds.add(campaignId);
2138
+ if (kind === 'reaction') {
2139
+ const current = this.state.post;
2140
+ if (current?.id === campaignId && current.reacted === value) {
2141
+ this.setState({
2142
+ post: {
2143
+ ...current,
2144
+ reacted: post.reacted
2145
+ }
2146
+ });
2147
+ // Not saved: it stays, as it was, so they can try again — unless an
2148
+ // earlier pick was saved, which already answered it: that one leaves.
2149
+ if (post.reacted) this.startPostThanks(campaignId, Promise.resolve(), session);else this.clearPostThanks();
2150
+ }
2151
+ }
2152
+ return undefined;
2153
+ }
2154
+ if (this.postStale(session)) return undefined;
2155
+ if (kind === 'reply') {
2156
+ // Falls back to what was typed when the response has no usable reply.
2157
+ const saved = result?.reply;
2158
+ const reply = typeof saved?.text === 'string' && saved.text ? {
2159
+ text: saved.text,
2160
+ at: typeof saved.at === 'string' ? saved.at : new Date().toISOString()
2161
+ } : {
2162
+ text: value ?? '',
2163
+ at: new Date().toISOString()
2164
+ };
2165
+ const current = this.state.post;
2166
+ if (current?.id === campaignId) {
2167
+ // Their first reply starts the post's conversation: answers there join the thread from now on.
2168
+ const conversationId = typeof result?.conversationId === 'string' && result.conversationId ? result.conversationId : current.conversationId;
2169
+ this.postRevision += 1;
2170
+ this.setState({
2171
+ post: {
2172
+ ...current,
2173
+ replies: [...current.replies, reply],
2174
+ thread: [...current.thread, {
2175
+ from: 'person',
2176
+ text: reply.text,
2177
+ senderName: null,
2178
+ at: reply.at
2179
+ }],
2180
+ conversationId
2181
+ }
2182
+ });
2183
+ }
2184
+ // The reply went to the post's conversation: show it in the Messenger's list.
2185
+ void this.refreshConversations();
2186
+ return reply;
2187
+ }
2188
+ // A reaction's post leaves when its thanks ends (see startPostThanks).
2189
+ if (result?.hidden && kind !== 'reaction') {
2190
+ if (this.state.post?.id === campaignId) this.showPost(null);
2191
+ this.loadPost();
2192
+ }
2193
+ return undefined;
2194
+ }
2195
+ startPostThanks(campaignId, sent, session) {
2196
+ this.clearPostThanks();
2197
+ this.postThanksTimer = setTimeout(() => {
2198
+ this.postThanksTimer = null;
2199
+ if (this.postStale(session) || this.state.post?.id !== campaignId) return;
2200
+ this.postRequest += 1;
2201
+ this.showPost(null);
2202
+ // Ask for the next one once the server has the reaction, so it doesn't send this one back.
2203
+ sent.then(() => {
2204
+ if (!this.postStale(session)) this.loadPost();
2205
+ }, () => {
2206
+ if (!this.postStale(session)) this.loadPost();
2207
+ });
2208
+ }, POST_THANKS_MS);
2209
+ }
2210
+ clearPostThanks() {
2211
+ if (this.postThanksTimer) clearTimeout(this.postThanksTimer);
2212
+ this.postThanksTimer = null;
2213
+ }
2214
+ resendPostDismiss(campaignId, session) {
2215
+ this.client.sendPostInteraction(campaignId, 'dismiss').then(() => {
2216
+ if (this.postStale(session)) return;
2217
+ this.unsentDismissIds.delete(campaignId);
2218
+ this.loadPost();
2219
+ })
2220
+ // Still offline: it stays down and the next load tries again.
2221
+ .catch(() => {});
2222
+ }
2223
+
2224
+ /**
2225
+ * A post push was tapped: show that post, open in full, as soon as the
2226
+ * session is ready. The messenger steps aside for it, the way a reply push
2227
+ * opens its conversation.
2228
+ */
2229
+ openPostFromPush(campaignId) {
2230
+ if (!this.postsEnabled) return;
2231
+ if (this.state.isOpen) this.hide();
2232
+ this.pendingPostId = campaignId;
2233
+ if (this.state.post?.id === campaignId) {
2234
+ this.pendingPostId = null;
2235
+ this.expandPost(campaignId);
2236
+ return;
2237
+ }
2238
+ if (this.state.booted) this.loadPost();
2239
+ }
2240
+ clearPostRecheck() {
2241
+ if (this.postRecheckTimer) clearTimeout(this.postRecheckTimer);
2242
+ this.postRecheckTimer = null;
2243
+ }
2244
+
2245
+ /** The server knows when the shown post stops; ask again then, so it leaves on time. */
2246
+ schedulePostRecheck(recheckInMs, session) {
2247
+ this.clearPostRecheck();
2248
+ if (typeof recheckInMs !== 'number' || !Number.isFinite(recheckInMs)) return;
2249
+ const delay = Math.min(Math.max(recheckInMs, 0) + POST_RECHECK_SLACK_MS, POST_RECHECK_MAX_MS);
2250
+ this.postRecheckTimer = setTimeout(() => {
2251
+ this.postRecheckTimer = null;
2252
+ if (!this.postStale(session)) this.loadPost();
2253
+ }, delay);
2254
+ }
2255
+
2256
+ /**
2257
+ * Post callbacks check this, not the identity epoch: a same-user login or
2258
+ * an anonymous re-login bumps the epoch but keeps the session (and the post),
2259
+ * and would otherwise drop the post's load and strand its timers.
2260
+ */
2261
+ postStale(session) {
2262
+ return session !== this.postSession;
2263
+ }
2264
+
2265
+ /** The visitor changed or posts were turned off: drop it and any pending load. */
2266
+ resetPost() {
2267
+ this.postSession += 1;
2268
+ this.clearPostRecheck();
2269
+ this.clearPostThanks();
2270
+ this.postRequest += 1;
2271
+ this.viewedPostIds.clear();
2272
+ this.unsentDismissIds.clear();
2273
+ this.postUnshownReplies.clear();
2274
+ if (this.state.post || this.state.postExpanded) this.setState({
2275
+ post: null,
2276
+ postExpanded: false
2277
+ });
2278
+ }
2279
+
1801
2280
  // ─── Teardown (tests / hot reload) ──────────────────────────────────────
1802
2281
 
1803
2282
  destroy() {
2283
+ this.clearPostRecheck();
2284
+ this.clearPostThanks();
1804
2285
  this.realtime.close();
1805
2286
  this.clearTypingTimer();
1806
2287
  this.resetThreadTracking();
@@ -1827,11 +2308,6 @@ function isLocalMessage(message) {
1827
2308
  return message.id.startsWith('temp-');
1828
2309
  }
1829
2310
 
1830
- /**
1831
- * A sent visitor message keeps its local previews (a `file://` URI, or no URL
1832
- * at all) until the server copy shows up — the stream echo or a history page.
1833
- * Its attachments, the only ones that can be opened, then replace them.
1834
- */
1835
2311
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
1836
2312
  function adoptServerStatuses(messages, server) {
1837
2313
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -1873,6 +2349,12 @@ function adoptServerDeletions(messages, server) {
1873
2349
  });
1874
2350
  return changed ? next : messages;
1875
2351
  }
2352
+
2353
+ /**
2354
+ * A sent visitor message keeps its local previews (a `file://` URI, or no URL
2355
+ * at all) until the server copy shows up — the stream echo or a history page.
2356
+ * Its attachments, the only ones that can be opened, then replace them.
2357
+ */
1876
2358
  function adoptServerAttachments(messages, server) {
1877
2359
  const serverCopies = new Map(server.filter(message => message.attachments?.length).map(message => [message.id, message]));
1878
2360
  if (serverCopies.size === 0) return messages;