@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
@@ -25,10 +25,13 @@ import type {
25
25
  ConversationRatingResult,
26
26
  Message,
27
27
  OutgoingAttachment,
28
+ PostInteractionKind,
29
+ PostReply,
28
30
  RepzoWidgetErrorCode,
29
31
  WidgetAttachment,
30
32
  WidgetConfig,
31
33
  WidgetCustomAttributes,
34
+ WidgetPost,
32
35
  WidgetSpaceType,
33
36
  WidgetState,
34
37
  WidgetUser,
@@ -36,7 +39,8 @@ import type {
36
39
  } from '../types';
37
40
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
38
41
 
39
- const SDK_VERSION = '@repzo/widget-react-native@1.0.5';
42
+ /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
43
+ const SDK_VERSION = '@repzo/widget-react-native@1.2.0';
40
44
 
41
45
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
42
46
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -65,6 +69,12 @@ const STREAM_REAUTH_MIN_INTERVAL_MS = 30_000;
65
69
  const MARK_READ_DEBOUNCE_MS = 1_000;
66
70
  /** Live events held back while a draft's first send is creating its thread. */
67
71
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
72
+ /** How long a post stays up after a reaction, thanking them, before it goes. */
73
+ export const POST_THANKS_MS = 1600;
74
+
75
+ /** Ask a moment after the server's "post stops" time, never waiting longer than this per timer. */
76
+ const POST_RECHECK_SLACK_MS = 1_000;
77
+ const POST_RECHECK_MAX_MS = 6 * 60 * 60 * 1000;
68
78
  /** Plain-http `baseUrl` is only accepted for these (plus `*.local` and `__DEV__`). */
69
79
  const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '10.0.2.2']);
70
80
 
@@ -95,6 +105,8 @@ const INITIAL_STATE: WidgetState = {
95
105
  outboundMessage: null,
96
106
  visitorProfile: {},
97
107
  inAppNotification: null,
108
+ post: null,
109
+ postExpanded: false,
98
110
  };
99
111
 
100
112
  /** Everything in state that belongs to the signed-in visitor. */
@@ -115,6 +127,8 @@ const USER_SCOPED_STATE: Partial<WidgetState> = {
115
127
  outboundMessage: null,
116
128
  visitorProfile: {},
117
129
  inAppNotification: null,
130
+ post: null,
131
+ postExpanded: false,
118
132
  };
119
133
 
120
134
  export class WidgetStore {
@@ -122,9 +136,13 @@ export class WidgetStore {
122
136
 
123
137
  readonly events = new EventEmitter();
124
138
 
125
- readonly push = new PushManager(this.client, (conversationId) => {
126
- void this.openConversation(conversationId);
127
- });
139
+ readonly push = new PushManager(
140
+ this.client,
141
+ (conversationId) => {
142
+ void this.openConversation(conversationId);
143
+ },
144
+ (campaignId) => this.openPostFromPush(campaignId)
145
+ );
128
146
 
129
147
  private state: WidgetState = INITIAL_STATE;
130
148
 
@@ -144,6 +162,35 @@ export class WidgetStore {
144
162
  /** `boot({ inAppNotifications })` — the card over the app for new replies. */
145
163
  private inAppNotificationsEnabled = true;
146
164
 
165
+ /** `boot({ posts })` — Send posts over the app. */
166
+ private postsEnabled = true;
167
+
168
+ /** Posts whose view was reported this session (the server also dedupes per day). */
169
+ private viewedPostIds = new Set<string>();
170
+
171
+ /** Bumped per post load, so an older answer never lands over a newer one. */
172
+ private postRequest = 0;
173
+
174
+ /** Bumped when a live answer or a saved reply joins the post, so a load sent before it is retried, not shown. */
175
+ private postRevision = 0;
176
+
177
+ /** Conversations whose unread replies include one the post can't show (attachments only): opening the post leaves them unread. */
178
+ private postUnshownReplies = new Set<string>();
179
+
180
+ private postRecheckTimer: ReturnType<typeof setTimeout> | null = null;
181
+
182
+ /** A post push tapped before the session was ready: open it once it is. */
183
+ private pendingPostId: string | null = null;
184
+
185
+ /** Posts closed with × whose report failed (offline, say): kept down, and the close re-sent when they come back. */
186
+ private unsentDismissIds = new Set<string>();
187
+
188
+ /** Bumped when the visitor's posts are reset (logout, wipe, posts off): older post callbacks stand down. */
189
+ private postSession = 0;
190
+
191
+ /** A reaction's "thanks" moment: when it ends, the post goes. */
192
+ private postThanksTimer: ReturnType<typeof setTimeout> | null = null;
193
+
147
194
  /** The launch card (see showUnreadOnLaunch) is offered once per visitor session. */
148
195
  private launchCardOffered = false;
149
196
 
@@ -313,6 +360,17 @@ export class WidgetStore {
313
360
  if (options.inAppNotifications !== undefined) {
314
361
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
315
362
  }
363
+ if (options.posts !== undefined) {
364
+ const wasEnabled = this.postsEnabled;
365
+ this.postsEnabled = options.posts !== false;
366
+ if (!this.postsEnabled) {
367
+ this.resetPost();
368
+ this.pendingPostId = null;
369
+ } else if (!wasEnabled) {
370
+ // A booted session returns early below, so turning posts back on asks here.
371
+ this.loadPost();
372
+ }
373
+ }
316
374
 
317
375
  const sameTarget =
318
376
  this.bootConfig?.baseUrl === baseUrl &&
@@ -517,6 +575,8 @@ export class WidgetStore {
517
575
  this.connectRealtime(result.token);
518
576
  this.watchAppState();
519
577
  this.loadOutboundMessage();
578
+ // Audiences and closed posts are per person, so every new session asks again.
579
+ this.loadPost();
520
580
  // Re-runs on login switches too — the token moves to the new identity.
521
581
  void this.push.start();
522
582
 
@@ -723,6 +783,8 @@ export class WidgetStore {
723
783
  this.resetThreadTracking();
724
784
  this.appStateSubscription?.remove();
725
785
  this.appStateSubscription = null;
786
+ this.resetPost();
787
+ this.pendingPostId = null;
726
788
  this.setState({ ...INITIAL_STATE });
727
789
 
728
790
  await this.enqueueIdentity(() => this.wipeSession());
@@ -755,9 +817,11 @@ export class WidgetStore {
755
817
  this.userKeyLoaded = true;
756
818
  this.pendingAgentByConversation = {};
757
819
  this.seenMessageIds = [];
820
+ this.earlyStatuses.clear();
758
821
  this.visitorTypingActive = false;
759
822
  this.lastTypingPingAt = 0;
760
823
  this.launchCardOffered = false;
824
+ this.resetPost();
761
825
  this.setState({ ...USER_SCOPED_STATE });
762
826
 
763
827
  const token = await this.client.getToken();
@@ -875,6 +939,8 @@ export class WidgetStore {
875
939
  // and the thread on screen so replies sent meanwhile show up.
876
940
  await Promise.all([this.refreshConversations(), this.refreshOpenThread()]);
877
941
  await this.refreshConfig();
942
+ // A post may have gone live (or ended) while the app was away.
943
+ if (!this.isStale(epoch)) this.loadPost();
878
944
  }
879
945
 
880
946
  /**
@@ -920,6 +986,8 @@ export class WidgetStore {
920
986
  if (this.streamConnectedOnce || this.streamErrored) {
921
987
  void this.refreshConversations();
922
988
  void this.refreshOpenThread();
989
+ // Answers under the post too.
990
+ this.loadPost();
923
991
  }
924
992
  this.streamConnectedOnce = true;
925
993
  this.streamErrored = false;
@@ -1071,12 +1139,6 @@ export class WidgetStore {
1071
1139
  return false;
1072
1140
  }
1073
1141
 
1074
- /**
1075
- * A teammate deleted a message: the visitor sees "This message was deleted"
1076
- * in its place (they can't delete anything themselves), and the list is
1077
- * refreshed so its preview stops showing the text. The id stays in
1078
- * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1079
- */
1080
1142
  /**
1081
1143
  * A teammate opened the conversation: the visitor's messages are now Seen.
1082
1144
  * The ids are also remembered, because the event can arrive before the send
@@ -1086,9 +1148,15 @@ export class WidgetStore {
1086
1148
  const ids: string[] = Array.isArray(data?.messageIds) ? data.messageIds : [];
1087
1149
  const status: string | undefined = typeof data?.status === 'string' ? data.status : undefined;
1088
1150
  if (!status || ids.length === 0) return;
1089
- for (const id of ids) this.earlyStatuses.set(id, status);
1090
- if (this.earlyStatuses.size > SEEN_MESSAGE_LIMIT) {
1091
- this.earlyStatuses.delete(this.earlyStatuses.keys().next().value as string);
1151
+ for (const id of ids) {
1152
+ // Re-inserted, so a repeated id counts as newest when trimming.
1153
+ this.earlyStatuses.delete(id);
1154
+ this.earlyStatuses.set(id, status);
1155
+ }
1156
+ // One event can carry many ids: trim back to the cap, oldest first.
1157
+ for (const id of this.earlyStatuses.keys()) {
1158
+ if (this.earlyStatuses.size <= SEEN_MESSAGE_LIMIT) break;
1159
+ this.earlyStatuses.delete(id);
1092
1160
  }
1093
1161
  if (!this.isThreadOnScreen(data?.conversationId)) return;
1094
1162
  this.setState((current) => ({
@@ -1098,6 +1166,12 @@ export class WidgetStore {
1098
1166
  }));
1099
1167
  }
1100
1168
 
1169
+ /**
1170
+ * A teammate deleted a message: the visitor sees "This message was deleted"
1171
+ * in its place (they can't delete anything themselves), and the list is
1172
+ * refreshed so its preview stops showing the text. The id stays in
1173
+ * `seenMessageIds`, so a replayed `message.new` can't restore the content.
1174
+ */
1101
1175
  private handleDeletedMessage(data: any) {
1102
1176
  const messageId: string | undefined = data?.messageId;
1103
1177
  if (!messageId) return;
@@ -1147,6 +1221,18 @@ export class WidgetStore {
1147
1221
  message.direction !== 'inbound' &&
1148
1222
  message.senderType !== 'visitor';
1149
1223
  const onScreen = this.isThreadOnScreen(conversationId);
1224
+ // A team answer in the shown post's conversation shows under the post —
1225
+ // and, with the post open in front of them, counts as read there. The
1226
+ // post only shows text: an attachment-only answer stays unread (card,
1227
+ // badge) for the messenger, and so does everything after it until the
1228
+ // conversation is opened there, since reading moves the server cursor past it.
1229
+ const post = this.state.post;
1230
+ const inPost = isTeamReply && !!conversationId && post?.conversationId === conversationId;
1231
+ const answerText = (message.bodyPlain ?? '').trim();
1232
+ if (inPost && !this.state.unreadByConversation[conversationId]) this.postUnshownReplies.delete(conversationId);
1233
+ const readInPost =
1234
+ inPost && !!answerText && this.state.postExpanded && !this.state.isOpen && !this.state.unreadByConversation[conversationId];
1235
+ if (inPost && !answerText) this.postUnshownReplies.add(conversationId);
1150
1236
 
1151
1237
  // Set inside the updater, read after: `setState` runs synchronously.
1152
1238
  let countedAsUnread = false;
@@ -1179,7 +1265,7 @@ export class WidgetStore {
1179
1265
  };
1180
1266
  }
1181
1267
 
1182
- if (!isTeamReply || !conversationId) return { conversations };
1268
+ if (!isTeamReply || !conversationId || readInPost) return { conversations };
1183
1269
 
1184
1270
  const unreadByConversation = {
1185
1271
  ...current.unreadByConversation,
@@ -1209,7 +1295,14 @@ export class WidgetStore {
1209
1295
 
1210
1296
  // The visitor is reading it live — move the server cursor too, or the
1211
1297
  // next list/init would badge a reply they already saw.
1212
- if (onScreen && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1298
+ if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
1299
+
1300
+ const current = this.state.post;
1301
+ if (inPost && answerText && current?.id === post?.id) {
1302
+ const answer = { from: 'team' as const, text: answerText, senderName: message.senderName, at: message.createdAt };
1303
+ this.postRevision += 1;
1304
+ this.setState({ post: { ...current, thread: [...current.thread, answer] } });
1305
+ }
1213
1306
 
1214
1307
  // Emitted after the state write so a listener reading the unread count sees
1215
1308
  // the new value. Only unread-counting replies fire it, which keeps the event
@@ -1263,7 +1356,7 @@ export class WidgetStore {
1263
1356
  body: null,
1264
1357
  bodyPlain: data.bodyPlain || 'Conversation updated',
1265
1358
  attachments: null,
1266
- metadata: data.metadata || null,
1359
+ metadata: this.hydrateMetadata(data.metadata || null),
1267
1360
  status: 'delivered',
1268
1361
  createdAt: data.createdAt || new Date().toISOString(),
1269
1362
  };
@@ -1391,6 +1484,8 @@ export class WidgetStore {
1391
1484
  this.setState({ isOpen: false, view: 'bubble', agentTyping: false });
1392
1485
  this.clearTypingTimer();
1393
1486
  this.track('widget_closed');
1487
+ // A post left open behind the messenger is back in front of them.
1488
+ this.readPostConversation();
1394
1489
  }
1395
1490
 
1396
1491
  toggle(space?: WidgetSpaceType): void {
@@ -1667,10 +1762,35 @@ export class WidgetStore {
1667
1762
  .reverse();
1668
1763
  }
1669
1764
 
1670
- /** The "joined" line's avatar can be a server-relative media path. */
1765
+ /**
1766
+ * Media URLs inside metadata (the "joined" line's avatar, product photos in
1767
+ * agent blocks) can be server-relative paths, and are remote content: like
1768
+ * every other media URL they resolve against `baseUrl`, and anything that
1769
+ * isn't http(s) is dropped before it reaches an `<Image>`.
1770
+ */
1671
1771
  private hydrateMetadata(metadata: Message['metadata']): Message['metadata'] {
1672
- if (!metadata || typeof metadata.agentAvatar !== 'string') return metadata ?? null;
1673
- return { ...metadata, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
1772
+ if (!metadata) return null;
1773
+ let next = metadata;
1774
+ if (typeof metadata.agentAvatar === 'string') {
1775
+ next = { ...next, agentAvatar: this.client.resolveUrl(metadata.agentAvatar) };
1776
+ }
1777
+ if (Array.isArray(metadata.agentUiBlocks)) {
1778
+ next = {
1779
+ ...next,
1780
+ agentUiBlocks: metadata.agentUiBlocks.map((block: unknown) => this.hydrateBlockMedia(block)),
1781
+ };
1782
+ }
1783
+ return next;
1784
+ }
1785
+
1786
+ private hydrateBlockMedia(block: unknown): unknown {
1787
+ const candidate = block as { type?: unknown; product?: { imageUrl?: unknown } } | null;
1788
+ const imageUrl = candidate?.product?.imageUrl;
1789
+ if (candidate?.type !== 'product_service_card' || typeof imageUrl !== 'string') return block;
1790
+ return {
1791
+ ...candidate,
1792
+ product: { ...candidate.product, imageUrl: this.client.resolveUrl(imageUrl) ?? undefined },
1793
+ };
1674
1794
  }
1675
1795
 
1676
1796
  /**
@@ -1933,9 +2053,345 @@ export class WidgetStore {
1933
2053
  .catch(() => {});
1934
2054
  }
1935
2055
 
2056
+ // ─── Send posts ─────────────────────────────────────────────────────────
2057
+
2058
+ /** Ask the server for the post this visitor should see now. */
2059
+ loadPost(): void {
2060
+ if (!this.postsEnabled || !this.state.booted) return;
2061
+ const session = this.postSession;
2062
+ const request = ++this.postRequest;
2063
+ const revision = this.postRevision;
2064
+ this.client
2065
+ .getPost()
2066
+ .then(({ post, recheckInMs }) => {
2067
+ if (this.postStale(session) || request !== this.postRequest) return;
2068
+ // An answer or reply joined the post meanwhile, maybe after this was
2069
+ // read: ask again rather than show the post without it.
2070
+ if (revision !== this.postRevision) {
2071
+ this.loadPost();
2072
+ return;
2073
+ }
2074
+ const hydrated = post ? this.hydratePost(post) : null;
2075
+ const pushed = this.pendingPostId;
2076
+ this.pendingPostId = null;
2077
+ this.schedulePostRecheck(recheckInMs, session);
2078
+ if (hydrated && this.unsentDismissIds.has(hydrated.id)) {
2079
+ // Closed already; the server only missed hearing it.
2080
+ this.showPost(null);
2081
+ this.resendPostDismiss(hydrated.id, session);
2082
+ return;
2083
+ }
2084
+ // A full post opens by itself unless they minimized it; a tapped push always opens it.
2085
+ this.showPost(hydrated, !!hydrated && ((hydrated.sentAs === 'full' && !hydrated.minimized) || hydrated.id === pushed));
2086
+ })
2087
+ // Keep whatever is on screen; the next session or foreground asks again.
2088
+ .catch(() => {});
2089
+ }
2090
+
2091
+ /**
2092
+ * Coerces the server's post into the shape the view relies on: missing
2093
+ * strings become empty, unknown enum values fall back to the plainest
2094
+ * option, and malformed reactions or replies are dropped.
2095
+ */
2096
+ private hydratePost(post: WidgetPost): WidgetPost | null {
2097
+ if (!post || typeof post.id !== 'string' || !post.id || typeof post.bodyHtml !== 'string') return null;
2098
+ const text = (value: unknown) => (typeof value === 'string' ? value : '');
2099
+ const sender = post.sender && text(post.sender.name) ? post.sender : null;
2100
+ const replies = Array.isArray(post.replies)
2101
+ ? post.replies
2102
+ .filter((reply) => typeof reply?.text === 'string' && reply.text.length > 0)
2103
+ .map((reply) => ({ text: reply.text, at: text(reply.at) }))
2104
+ : [];
2105
+ return {
2106
+ id: post.id,
2107
+ bodyHtml: post.bodyHtml,
2108
+ snippet: text(post.snippet),
2109
+ sender: sender
2110
+ ? {
2111
+ name: sender.name,
2112
+ initials: text(sender.initials),
2113
+ // Teammate photos can be server-relative media paths.
2114
+ avatarUrl: this.client.resolveUrl(typeof sender.avatarUrl === 'string' ? sender.avatarUrl : null),
2115
+ }
2116
+ : null,
2117
+ workspaceName: text(post.workspaceName),
2118
+ format: post.format === 'large' ? 'large' : 'small',
2119
+ replyType: post.replyType === 'text' || post.replyType === 'reaction' ? post.replyType : 'none',
2120
+ reactions: Array.isArray(post.reactions)
2121
+ ? post.reactions.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
2122
+ : [],
2123
+ sentAs: post.sentAs === 'badge' || post.sentAs === 'full' ? post.sentAs : 'snippet',
2124
+ reacted: typeof post.reacted === 'string' && post.reacted ? post.reacted : null,
2125
+ // Older Workstation versions don't send it.
2126
+ minimized: post.minimized === true,
2127
+ replies,
2128
+ thread: Array.isArray(post.thread)
2129
+ ? post.thread
2130
+ .filter((message) => typeof message?.text === 'string' && message.text.length > 0)
2131
+ .map((message) => ({
2132
+ from: message.from === 'team' ? ('team' as const) : ('person' as const),
2133
+ text: message.text,
2134
+ senderName: message.from === 'team' ? text(message.senderName) || null : null,
2135
+ at: text(message.at),
2136
+ }))
2137
+ : replies.map((reply) => ({ from: 'person' as const, text: reply.text, senderName: null, at: reply.at })),
2138
+ conversationId: text(post.conversationId) || null,
2139
+ };
2140
+ }
2141
+
2142
+ /**
2143
+ * Puts a post on screen (or takes it down). A different post starts open or
2144
+ * as its card per `expand`; the same one keeps what the visitor left it as,
2145
+ * so a full post put back to its card doesn't pop open on every foreground.
2146
+ */
2147
+ private showPost(post: WidgetPost | null, expand = false): void {
2148
+ const current = this.state.post;
2149
+ if (current && current.id !== post?.id) this.viewedPostIds.delete(current.id);
2150
+ const samePost = !!post && current?.id === post.id;
2151
+ const expanded = !!post && (samePost ? this.state.postExpanded : expand);
2152
+ this.setState({ post, postExpanded: expanded });
2153
+ if (post && expanded && !samePost) this.reportPostOpen(post);
2154
+ this.readPostConversation();
2155
+ }
2156
+
2157
+ /** The post's card or full view is on screen. Reported once each time it's shown. */
2158
+ postDidShow(campaignId: string): void {
2159
+ if (this.state.post?.id !== campaignId || this.viewedPostIds.has(campaignId)) return;
2160
+ this.viewedPostIds.add(campaignId);
2161
+ this.client.sendPostInteraction(campaignId, 'view').catch(() => {});
2162
+ }
2163
+
2164
+ /** The visitor tapped the post's card: open it in full. */
2165
+ expandPost(campaignId: string): void {
2166
+ const post = this.state.post;
2167
+ if (!post || post.id !== campaignId || this.state.postExpanded) return;
2168
+ this.setState({ postExpanded: true });
2169
+ this.reportPostOpen(post);
2170
+ this.readPostConversation();
2171
+ }
2172
+
2173
+ /**
2174
+ * The post is open in front of them (and the messenger isn't): the team's
2175
+ * answers under it are read, here and on the server. Not when one of them
2176
+ * is something the post can't show, or the post has fewer answers since
2177
+ * their last reply than are unread (an attachment from before this launch,
2178
+ * say): those wait for the messenger.
2179
+ */
2180
+ private readPostConversation(): void {
2181
+ const { post, postExpanded, isOpen, unreadByConversation } = this.state;
2182
+ const conversationId = post?.conversationId;
2183
+ const unread = conversationId ? (unreadByConversation[conversationId] ?? 0) : 0;
2184
+ if (!post || !conversationId || !postExpanded || isOpen || unread === 0) return;
2185
+ if (this.postUnshownReplies.has(conversationId)) return;
2186
+ let shown = 0;
2187
+ for (let index = post.thread.length - 1; index >= 0 && post.thread[index]?.from === 'team'; index -= 1) shown += 1;
2188
+ if (shown < unread) return;
2189
+ this.setState({ unreadByConversation: this.clearUnreadFor(conversationId) });
2190
+ this.events.emit(RepzoWidgetEvents.ConversationDidRead, { conversationId });
2191
+ this.scheduleMarkRead(conversationId);
2192
+ }
2193
+
2194
+ /** Back to its pill or card for now, without telling the server (see PostOverlay's onCollapse). */
2195
+ collapsePost(campaignId: string): void {
2196
+ if (this.state.post?.id !== campaignId || !this.state.postExpanded) return;
2197
+ this.setState({ postExpanded: false });
2198
+ }
2199
+
2200
+ /**
2201
+ * "Not now" — the ⌄ button, a swipe down, or Android's back: the post goes
2202
+ * back to its badge (or snippet card) and stays there, on later launches
2203
+ * too, instead of opening by itself again. Unlike ×, it can be opened again.
2204
+ */
2205
+ minimizePost(campaignId: string): void {
2206
+ const post = this.state.post;
2207
+ if (!post || post.id !== campaignId || !this.state.postExpanded) return;
2208
+ this.setState({ post: { ...post, minimized: true }, postExpanded: false });
2209
+ if (!post.minimized) this.client.sendPostInteraction(campaignId, 'minimize').catch(() => {});
2210
+ }
2211
+
2212
+ private reportPostOpen(post: WidgetPost): void {
2213
+ // "Opened" is opening it from its card; a full post is opened by showing it.
2214
+ if (post.sentAs === 'full') return;
2215
+ this.client.sendPostInteraction(post.id, 'open').catch(() => {});
2216
+ }
2217
+
2218
+ /**
2219
+ * The visitor tapped a link, reacted, replied to or closed the post. Closing
2220
+ * takes it down at once and loads the next one; a reaction shows as picked
2221
+ * right away. A reply resolves with what was saved (shown under the post)
2222
+ * and rejects when it wasn't, so the reply box can keep the text.
2223
+ */
2224
+ async recordPostInteraction(
2225
+ campaignId: string,
2226
+ kind: Exclude<PostInteractionKind, 'view' | 'open' | 'minimize'>,
2227
+ value?: string
2228
+ ): Promise<PostReply | undefined> {
2229
+ const post = this.state.post;
2230
+ if (!post || post.id !== campaignId) return undefined;
2231
+ const session = this.postSession;
2232
+ if (kind === 'dismiss') {
2233
+ // A load already in flight would put the closed post back.
2234
+ this.postRequest += 1;
2235
+ this.showPost(null);
2236
+ }
2237
+ if (kind === 'reaction' && value) this.setState({ post: { ...post, reacted: value } });
2238
+
2239
+ const sent = this.client.sendPostInteraction(campaignId, kind, value);
2240
+ // A reaction answers the post: it thanks them for a moment, then goes
2241
+ // (another tap in the meantime changes their pick and restarts it).
2242
+ if (kind === 'reaction' && value) this.startPostThanks(campaignId, sent, session);
2243
+
2244
+ let result: { hidden: boolean; reply?: PostReply; conversationId?: string | null };
2245
+ try {
2246
+ result = await sent;
2247
+ } catch (error) {
2248
+ if (kind === 'reply') throw error;
2249
+ if (this.postStale(session)) return undefined;
2250
+ // × closes it for good, so it stays down until the server hears it.
2251
+ if (kind === 'dismiss') this.unsentDismissIds.add(campaignId);
2252
+ if (kind === 'reaction') {
2253
+ const current = this.state.post;
2254
+ if (current?.id === campaignId && current.reacted === value) {
2255
+ this.setState({ post: { ...current, reacted: post.reacted } });
2256
+ // Not saved: it stays, as it was, so they can try again — unless an
2257
+ // earlier pick was saved, which already answered it: that one leaves.
2258
+ if (post.reacted) this.startPostThanks(campaignId, Promise.resolve(), session);
2259
+ else this.clearPostThanks();
2260
+ }
2261
+ }
2262
+ return undefined;
2263
+ }
2264
+ if (this.postStale(session)) return undefined;
2265
+
2266
+ if (kind === 'reply') {
2267
+ // Falls back to what was typed when the response has no usable reply.
2268
+ const saved = result?.reply;
2269
+ const reply: PostReply =
2270
+ typeof saved?.text === 'string' && saved.text
2271
+ ? { text: saved.text, at: typeof saved.at === 'string' ? saved.at : new Date().toISOString() }
2272
+ : { text: value ?? '', at: new Date().toISOString() };
2273
+ const current = this.state.post;
2274
+ if (current?.id === campaignId) {
2275
+ // Their first reply starts the post's conversation: answers there join the thread from now on.
2276
+ const conversationId =
2277
+ typeof result?.conversationId === 'string' && result.conversationId ? result.conversationId : current.conversationId;
2278
+ this.postRevision += 1;
2279
+ this.setState({
2280
+ post: {
2281
+ ...current,
2282
+ replies: [...current.replies, reply],
2283
+ thread: [...current.thread, { from: 'person', text: reply.text, senderName: null, at: reply.at }],
2284
+ conversationId,
2285
+ },
2286
+ });
2287
+ }
2288
+ // The reply went to the post's conversation: show it in the Messenger's list.
2289
+ void this.refreshConversations();
2290
+ return reply;
2291
+ }
2292
+ // A reaction's post leaves when its thanks ends (see startPostThanks).
2293
+ if (result?.hidden && kind !== 'reaction') {
2294
+ if (this.state.post?.id === campaignId) this.showPost(null);
2295
+ this.loadPost();
2296
+ }
2297
+ return undefined;
2298
+ }
2299
+
2300
+ private startPostThanks(campaignId: string, sent: Promise<unknown>, session: number): void {
2301
+ this.clearPostThanks();
2302
+ this.postThanksTimer = setTimeout(() => {
2303
+ this.postThanksTimer = null;
2304
+ if (this.postStale(session) || this.state.post?.id !== campaignId) return;
2305
+ this.postRequest += 1;
2306
+ this.showPost(null);
2307
+ // Ask for the next one once the server has the reaction, so it doesn't send this one back.
2308
+ sent.then(
2309
+ () => {
2310
+ if (!this.postStale(session)) this.loadPost();
2311
+ },
2312
+ () => {
2313
+ if (!this.postStale(session)) this.loadPost();
2314
+ }
2315
+ );
2316
+ }, POST_THANKS_MS);
2317
+ }
2318
+
2319
+ private clearPostThanks(): void {
2320
+ if (this.postThanksTimer) clearTimeout(this.postThanksTimer);
2321
+ this.postThanksTimer = null;
2322
+ }
2323
+
2324
+ private resendPostDismiss(campaignId: string, session: number): void {
2325
+ this.client
2326
+ .sendPostInteraction(campaignId, 'dismiss')
2327
+ .then(() => {
2328
+ if (this.postStale(session)) return;
2329
+ this.unsentDismissIds.delete(campaignId);
2330
+ this.loadPost();
2331
+ })
2332
+ // Still offline: it stays down and the next load tries again.
2333
+ .catch(() => {});
2334
+ }
2335
+
2336
+ /**
2337
+ * A post push was tapped: show that post, open in full, as soon as the
2338
+ * session is ready. The messenger steps aside for it, the way a reply push
2339
+ * opens its conversation.
2340
+ */
2341
+ private openPostFromPush(campaignId: string): void {
2342
+ if (!this.postsEnabled) return;
2343
+ if (this.state.isOpen) this.hide();
2344
+ this.pendingPostId = campaignId;
2345
+ if (this.state.post?.id === campaignId) {
2346
+ this.pendingPostId = null;
2347
+ this.expandPost(campaignId);
2348
+ return;
2349
+ }
2350
+ if (this.state.booted) this.loadPost();
2351
+ }
2352
+
2353
+ private clearPostRecheck(): void {
2354
+ if (this.postRecheckTimer) clearTimeout(this.postRecheckTimer);
2355
+ this.postRecheckTimer = null;
2356
+ }
2357
+
2358
+ /** The server knows when the shown post stops; ask again then, so it leaves on time. */
2359
+ private schedulePostRecheck(recheckInMs: number | null | undefined, session: number): void {
2360
+ this.clearPostRecheck();
2361
+ if (typeof recheckInMs !== 'number' || !Number.isFinite(recheckInMs)) return;
2362
+ const delay = Math.min(Math.max(recheckInMs, 0) + POST_RECHECK_SLACK_MS, POST_RECHECK_MAX_MS);
2363
+ this.postRecheckTimer = setTimeout(() => {
2364
+ this.postRecheckTimer = null;
2365
+ if (!this.postStale(session)) this.loadPost();
2366
+ }, delay);
2367
+ }
2368
+
2369
+ /**
2370
+ * Post callbacks check this, not the identity epoch: a same-user login or
2371
+ * an anonymous re-login bumps the epoch but keeps the session (and the post),
2372
+ * and would otherwise drop the post's load and strand its timers.
2373
+ */
2374
+ private postStale(session: number): boolean {
2375
+ return session !== this.postSession;
2376
+ }
2377
+
2378
+ /** The visitor changed or posts were turned off: drop it and any pending load. */
2379
+ private resetPost(): void {
2380
+ this.postSession += 1;
2381
+ this.clearPostRecheck();
2382
+ this.clearPostThanks();
2383
+ this.postRequest += 1;
2384
+ this.viewedPostIds.clear();
2385
+ this.unsentDismissIds.clear();
2386
+ this.postUnshownReplies.clear();
2387
+ if (this.state.post || this.state.postExpanded) this.setState({ post: null, postExpanded: false });
2388
+ }
2389
+
1936
2390
  // ─── Teardown (tests / hot reload) ──────────────────────────────────────
1937
2391
 
1938
2392
  destroy(): void {
2393
+ this.clearPostRecheck();
2394
+ this.clearPostThanks();
1939
2395
  this.realtime.close();
1940
2396
  this.clearTypingTimer();
1941
2397
  this.resetThreadTracking();
@@ -1967,11 +2423,6 @@ function isLocalMessage(message: Message): boolean {
1967
2423
  return message.id.startsWith('temp-');
1968
2424
  }
1969
2425
 
1970
- /**
1971
- * A sent visitor message keeps its local previews (a `file://` URI, or no URL
1972
- * at all) until the server copy shows up — the stream echo or a history page.
1973
- * Its attachments, the only ones that can be opened, then replace them.
1974
- */
1975
2426
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
1976
2427
  function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
1977
2428
  const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
@@ -2006,6 +2457,11 @@ function adoptServerDeletions(messages: Message[], server: Message[]): Message[]
2006
2457
  return changed ? next : messages;
2007
2458
  }
2008
2459
 
2460
+ /**
2461
+ * A sent visitor message keeps its local previews (a `file://` URI, or no URL
2462
+ * at all) until the server copy shows up — the stream echo or a history page.
2463
+ * Its attachments, the only ones that can be opened, then replace them.
2464
+ */
2009
2465
  function adoptServerAttachments(messages: Message[], server: Message[]): Message[] {
2010
2466
  const serverCopies = new Map(
2011
2467
  server.filter((message) => message.attachments?.length).map((message) => [message.id, message])