@repzo/widget-react-native 1.3.0 → 1.4.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 (251) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +72 -16
  3. package/lib/commonjs/api/client.js +113 -0
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/attachments/file-size.js +52 -0
  6. package/lib/commonjs/attachments/file-size.js.map +1 -0
  7. package/lib/commonjs/attachments/local-previews.js +40 -0
  8. package/lib/commonjs/attachments/local-previews.js.map +1 -0
  9. package/lib/commonjs/attachments/pickers.js +76 -28
  10. package/lib/commonjs/attachments/pickers.js.map +1 -1
  11. package/lib/commonjs/i18n.js +24 -3
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/locales.js +13 -3
  14. package/lib/commonjs/locales.js.map +1 -1
  15. package/lib/commonjs/state/widget-store.js +486 -63
  16. package/lib/commonjs/state/widget-store.js.map +1 -1
  17. package/lib/commonjs/storage.js +3 -1
  18. package/lib/commonjs/storage.js.map +1 -1
  19. package/lib/commonjs/types.js.map +1 -1
  20. package/lib/commonjs/ui/WidgetProvider.js +40 -16
  21. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  22. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  23. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  24. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  25. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  26. package/lib/commonjs/ui/components/ImageViewer.js +231 -25
  27. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  28. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  29. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  30. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  31. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  32. package/lib/commonjs/ui/components/icons.js +22 -16
  33. package/lib/commonjs/ui/components/icons.js.map +1 -1
  34. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  35. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  36. package/lib/commonjs/ui/screens/chat/ChatView.js +666 -171
  37. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  38. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +150 -3
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  40. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  41. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  42. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  43. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  44. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  45. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  46. package/lib/commonjs/ui/styles/theme.js +1 -0
  47. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  48. package/lib/commonjs/ui/utils/conversations.js +17 -1
  49. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  50. package/lib/commonjs/ui/utils/html.js +13 -0
  51. package/lib/commonjs/ui/utils/html.js.map +1 -1
  52. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  53. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  54. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  55. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  56. package/lib/module/api/client.js +113 -0
  57. package/lib/module/api/client.js.map +1 -1
  58. package/lib/module/attachments/file-size.js +47 -0
  59. package/lib/module/attachments/file-size.js.map +1 -0
  60. package/lib/module/attachments/local-previews.js +33 -0
  61. package/lib/module/attachments/local-previews.js.map +1 -0
  62. package/lib/module/attachments/pickers.js +74 -27
  63. package/lib/module/attachments/pickers.js.map +1 -1
  64. package/lib/module/i18n.js +24 -3
  65. package/lib/module/i18n.js.map +1 -1
  66. package/lib/module/locales.js +13 -3
  67. package/lib/module/locales.js.map +1 -1
  68. package/lib/module/state/widget-store.js +488 -65
  69. package/lib/module/state/widget-store.js.map +1 -1
  70. package/lib/module/storage.js +2 -0
  71. package/lib/module/storage.js.map +1 -1
  72. package/lib/module/types.js.map +1 -1
  73. package/lib/module/ui/WidgetProvider.js +40 -16
  74. package/lib/module/ui/WidgetProvider.js.map +1 -1
  75. package/lib/module/ui/components/AttachmentChips.js +111 -42
  76. package/lib/module/ui/components/AttachmentChips.js.map +1 -1
  77. package/lib/module/ui/components/ConversationRow.js +65 -10
  78. package/lib/module/ui/components/ConversationRow.js.map +1 -1
  79. package/lib/module/ui/components/ImageViewer.js +233 -27
  80. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  81. package/lib/module/ui/components/RatingCard.js +5 -2
  82. package/lib/module/ui/components/RatingCard.js.map +1 -1
  83. package/lib/module/ui/components/ZoomableImage.js +58 -10
  84. package/lib/module/ui/components/ZoomableImage.js.map +1 -1
  85. package/lib/module/ui/components/icons.js +21 -15
  86. package/lib/module/ui/components/icons.js.map +1 -1
  87. package/lib/module/ui/hooks/use-retained-state.js +34 -0
  88. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  89. package/lib/module/ui/screens/chat/ChatView.js +672 -177
  90. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  91. package/lib/module/ui/screens/chat/ChatView.styles.js +152 -4
  92. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  93. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  94. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  95. package/lib/module/ui/screens/home/HomeView.js +40 -57
  96. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  97. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  98. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  99. package/lib/module/ui/styles/theme.js +1 -0
  100. package/lib/module/ui/styles/theme.js.map +1 -1
  101. package/lib/module/ui/utils/conversations.js +15 -1
  102. package/lib/module/ui/utils/conversations.js.map +1 -1
  103. package/lib/module/ui/utils/html.js +12 -0
  104. package/lib/module/ui/utils/html.js.map +1 -1
  105. package/lib/module/ui/utils/image-load-queue.js +109 -0
  106. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  107. package/lib/module/ui/utils/inline-attachments.js +85 -0
  108. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  109. package/lib/typescript/commonjs/api/client.d.ts +35 -1
  110. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  112. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  114. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/attachments/pickers.d.ts +35 -10
  116. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/i18n.d.ts +13 -3
  118. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/state/widget-store.d.ts +67 -4
  121. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/storage.d.ts +2 -0
  123. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/types.d.ts +58 -0
  125. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  128. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  130. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  132. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  135. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  137. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  139. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  141. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  142. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +154 -2
  143. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  144. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +30 -1
  145. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  146. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  148. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  150. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  152. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  154. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  155. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  156. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  157. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  158. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  159. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  160. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  162. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  163. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  164. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  165. package/lib/typescript/module/api/client.d.ts +35 -1
  166. package/lib/typescript/module/api/client.d.ts.map +1 -1
  167. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  168. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  169. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  170. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  171. package/lib/typescript/module/attachments/pickers.d.ts +35 -10
  172. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  173. package/lib/typescript/module/i18n.d.ts +13 -3
  174. package/lib/typescript/module/i18n.d.ts.map +1 -1
  175. package/lib/typescript/module/locales.d.ts.map +1 -1
  176. package/lib/typescript/module/state/widget-store.d.ts +67 -4
  177. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  178. package/lib/typescript/module/storage.d.ts +2 -0
  179. package/lib/typescript/module/storage.d.ts.map +1 -1
  180. package/lib/typescript/module/types.d.ts +58 -0
  181. package/lib/typescript/module/types.d.ts.map +1 -1
  182. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  184. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  186. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  187. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  188. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  189. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  190. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  191. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  192. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  193. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  194. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  195. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  196. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  197. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  198. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +154 -2
  199. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  200. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +30 -1
  201. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  202. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  203. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  204. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  205. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  206. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  207. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  208. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  209. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  210. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  211. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  212. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  213. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  214. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  215. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  216. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  217. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  218. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  219. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  220. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  221. package/package.json +1 -1
  222. package/src/api/client.ts +148 -2
  223. package/src/attachments/file-size.ts +42 -0
  224. package/src/attachments/local-previews.ts +32 -0
  225. package/src/attachments/pickers.ts +90 -30
  226. package/src/i18n.ts +24 -3
  227. package/src/locales.ts +13 -3
  228. package/src/state/widget-store.ts +519 -70
  229. package/src/storage.ts +2 -0
  230. package/src/types.ts +58 -0
  231. package/src/ui/WidgetProvider.tsx +50 -12
  232. package/src/ui/components/AttachmentChips.tsx +100 -29
  233. package/src/ui/components/ConversationRow.tsx +90 -9
  234. package/src/ui/components/ImageViewer.tsx +250 -28
  235. package/src/ui/components/RatingCard.tsx +5 -2
  236. package/src/ui/components/ZoomableImage.tsx +58 -5
  237. package/src/ui/components/icons.tsx +10 -12
  238. package/src/ui/hooks/use-retained-state.ts +35 -0
  239. package/src/ui/screens/chat/ChatView.styles.ts +151 -3
  240. package/src/ui/screens/chat/ChatView.tsx +760 -213
  241. package/src/ui/screens/chat/ChatView.types.ts +30 -1
  242. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  243. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  244. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  245. package/src/ui/screens/home/HomeView.tsx +54 -70
  246. package/src/ui/screens/home/HomeView.types.ts +5 -6
  247. package/src/ui/styles/theme.ts +6 -0
  248. package/src/ui/utils/conversations.ts +16 -2
  249. package/src/ui/utils/html.ts +26 -0
  250. package/src/ui/utils/image-load-queue.ts +108 -0
  251. package/src/ui/utils/inline-attachments.ts +71 -0
@@ -11,8 +11,12 @@ var _deviceId = require("../device-id.js");
11
11
  var _events = require("../events.js");
12
12
  var _pushNotifications = require("../push/push-notifications.js");
13
13
  var _storage = require("../storage.js");
14
+ var _localPreviews = require("../attachments/local-previews.js");
14
15
  var _pickers = require("../attachments/pickers.js");
15
16
  var _types = require("../types.js");
17
+ var _useRetainedState = require("../ui/hooks/use-retained-state.js");
18
+ var _conversations = require("../ui/utils/conversations.js");
19
+ var _html = require("../ui/utils/html.js");
16
20
  var _safeUrl = require("../ui/utils/safe-url.js");
17
21
  /**
18
22
  * The widget's single source of truth.
@@ -25,7 +29,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
25
29
  */
26
30
 
27
31
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
28
- const SDK_VERSION = '@repzo/widget-react-native@1.3.0';
32
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
29
33
 
30
34
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
31
35
  function sessionPlatform() {
@@ -40,6 +44,12 @@ const TYPING_SETTLE_MS = 3_000;
40
44
  const TYPING_PING_INTERVAL_MS = 4_000;
41
45
  /** Guards against the SSE stream replaying messages after a reconnect. */
42
46
  const SEEN_MESSAGE_LIMIT = 100;
47
+ /** Threads whose newest message is remembered across launches. */
48
+ const LAST_MESSAGE_LIMIT = 50;
49
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
50
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
51
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
52
+ const LIST_LAG_MS = 1_000;
43
53
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
44
54
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
45
55
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -55,6 +65,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
55
65
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
56
66
  /** Live events held back while a draft's first send is creating its thread. */
57
67
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
68
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
69
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
58
70
  /** How long a post stays up after a reaction, thanking them, before it goes. */
59
71
  const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
60
72
 
@@ -131,6 +143,10 @@ class WidgetStore {
131
143
 
132
144
  /** `boot({ posts })` — Send posts over the app. */
133
145
  postsEnabled = true;
146
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
147
+ photoAlbumsEnabled = true;
148
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
149
+ youPrefixEnabled = true;
134
150
 
135
151
  /** Posts whose view was reported this session (the server also dedupes per day). */
136
152
  viewedPostIds = new Set();
@@ -185,8 +201,18 @@ class WidgetStore {
185
201
  lastTypingPingAt = 0;
186
202
  visitorTypingActive = false;
187
203
  seenMessageIds = [];
204
+
205
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
206
+ lastMessages = {};
207
+ lastMessagesLoaded = false;
188
208
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
189
209
  earlyStatuses = new Map();
210
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
211
+ failedActions = new Map();
212
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
213
+ serverCopyTimer = null;
214
+ serverCopyRetries = 0;
215
+ serverCopySyncing = false;
190
216
 
191
217
  /**
192
218
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -258,6 +284,8 @@ class WidgetStore {
258
284
 
259
285
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
260
286
  getThreadGeneration = () => this.threadGeneration;
287
+ showsPhotoAlbums = () => this.photoAlbumsEnabled;
288
+ showsYouPrefix = () => this.youPrefixEnabled;
261
289
  setState(patch) {
262
290
  const previous = this.state;
263
291
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -329,6 +357,8 @@ class WidgetStore {
329
357
  if (options.inAppNotifications !== undefined) {
330
358
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
331
359
  }
360
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
361
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
332
362
  if (options.posts !== undefined) {
333
363
  const wasEnabled = this.postsEnabled;
334
364
  this.postsEnabled = options.posts !== false;
@@ -483,13 +513,13 @@ class WidgetStore {
483
513
  // queued op must never see one without the other.
484
514
  await Promise.all([this.client.setToken(result.token), userKey ? _storage.SecureStorage.setItem(_storage.USER_KEY, userKey) : _storage.SecureStorage.removeItem(_storage.USER_KEY)]);
485
515
  if (this.isStale(epoch)) return;
486
- const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
487
516
 
488
517
  // Server counts win (they include replies pushed while the app was
489
518
  // killed); the locally persisted map only covers older Workstation
490
519
  // deployments that don't send unreadCount yet.
491
- const storedUnread = await this.loadUnread();
520
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
492
521
  if (this.isStale(epoch)) return;
522
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
493
523
  const unreadByConversation = {};
494
524
  for (const conversation of conversations) {
495
525
  const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
@@ -780,16 +810,20 @@ class WidgetStore {
780
810
  this.userKeyLoaded = true;
781
811
  this.pendingAgentByConversation = {};
782
812
  this.seenMessageIds = [];
813
+ this.lastMessages = {};
814
+ this.lastMessagesLoaded = false;
783
815
  this.earlyStatuses.clear();
784
816
  this.visitorTypingActive = false;
785
817
  this.lastTypingPingAt = 0;
786
818
  this.launchCardOffered = false;
787
819
  this.resetPost();
820
+ (0, _localPreviews.clearLocalPreviews)();
821
+ (0, _useRetainedState.clearRetainedState)();
788
822
  this.setState({
789
823
  ...USER_SCOPED_STATE
790
824
  });
791
825
  const token = await this.client.getToken();
792
- await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY),
826
+ await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY), _storage.Storage.removeItem(_storage.LAST_MESSAGE_KEY),
793
827
  // A new anonymous visitor on this device gets a new tracking identity.
794
828
  (0, _deviceId.rotateDeviceId)()]);
795
829
 
@@ -825,7 +859,9 @@ class WidgetStore {
825
859
  this.threadGeneration += 1;
826
860
  this.conversationCreation = null;
827
861
  this.draftEvents = [];
862
+ this.failedActions.clear();
828
863
  this.cancelMarkRead();
864
+ this.cancelServerCopySync();
829
865
  }
830
866
  requireBootConfig() {
831
867
  if (!this.bootConfig) {
@@ -1141,7 +1177,10 @@ class WidgetStore {
1141
1177
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1142
1178
  metadata: this.hydrateMetadata(data?.metadata || null),
1143
1179
  status: 'delivered',
1144
- createdAt: data?.createdAt || new Date().toISOString()
1180
+ createdAt: data?.createdAt || new Date().toISOString(),
1181
+ ...(typeof data?.clientMessageId === 'string' ? {
1182
+ clientMessageId: data.clientMessageId
1183
+ } : {})
1145
1184
  };
1146
1185
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1147
1186
  if (clearsTyping) this.clearTypingTimer();
@@ -1164,18 +1203,20 @@ class WidgetStore {
1164
1203
 
1165
1204
  // Set inside the updater, read after: `setState` runs synchronously.
1166
1205
  let countedAsUnread = false;
1206
+ const rowFields = rowFieldsOf(message);
1167
1207
  this.setState(current => {
1168
1208
  const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
1169
1209
  ...conversation,
1170
- snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
1210
+ ...rowFields,
1171
1211
  lastMessageAt: message.createdAt,
1172
1212
  messageCount: conversation.messageCount + 1
1173
1213
  } : conversation) : current.conversations;
1174
1214
  if (onScreen) {
1175
- const alreadyPresent = current.messages.some(existing => existing.id === message.id);
1215
+ const shown = adoptOwnServerRows(current.messages, [message]);
1216
+ const alreadyPresent = shown.some(existing => existing.id === message.id);
1176
1217
  return {
1177
1218
  conversations,
1178
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1219
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1179
1220
  agentTyping: clearsTyping ? false : current.agentTyping
1180
1221
  };
1181
1222
  }
@@ -1204,11 +1245,12 @@ class WidgetStore {
1204
1245
  messageId,
1205
1246
  senderName: message.senderName || conversation?.agentName || null,
1206
1247
  senderAvatar: conversation?.agentAvatar ?? null,
1207
- preview: (message.bodyPlain ?? '').trim(),
1248
+ preview: plainTextOf(message),
1208
1249
  hasAttachments: (message.attachments?.length ?? 0) > 0
1209
1250
  }
1210
1251
  };
1211
1252
  });
1253
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1212
1254
 
1213
1255
  // The visitor is reading it live — move the server cursor too, or the
1214
1256
  // next list/init would badge a reply they already saw.
@@ -1306,10 +1348,112 @@ class WidgetStore {
1306
1348
  };
1307
1349
  }
1308
1350
  hydrateConversation(conversation) {
1309
- return this.applyConversationAgent({
1351
+ return this.applyConversationAgent(this.withLastMessage({
1310
1352
  ...conversation,
1311
1353
  agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1354
+ }));
1355
+ }
1356
+
1357
+ /**
1358
+ * Workstation's list doesn't say who sent the newest message, and calls one
1359
+ * without words "2 attachments". While a row still describes the message
1360
+ * this device saw last, both are filled in from it. `snippet` keeps the
1361
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1362
+ * `lastMessageAttachment` instead.
1363
+ */
1364
+ withLastMessage(conversation) {
1365
+ const known = this.lastMessages[conversation.id];
1366
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1367
+ // A list that says who sent it, or what it carried, is used as is.
1368
+ return {
1369
+ ...conversation,
1370
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1371
+ lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
1372
+ };
1373
+ }
1374
+
1375
+ /**
1376
+ * A send or a streamed message is the newest by arrival, whatever the two
1377
+ * clocks say; a history page only replaces something older. A sent message
1378
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1379
+ * page that has it replaces it: whatever is newest there came after it, and
1380
+ * Workstation's own time then stands in for the phone's.
1381
+ */
1382
+ rememberLastMessage(conversationId, at, fields, source, {
1383
+ messageId,
1384
+ pageIds
1385
+ } = {}) {
1386
+ const known = this.lastMessages[conversationId];
1387
+ if (source === 'history' && known) {
1388
+ const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
1389
+ if (outdated) return;
1390
+ }
1391
+ const hint = {
1392
+ at,
1393
+ fromVisitor: !!fields.lastMessageFromVisitor,
1394
+ attachment: fields.lastMessageAttachment ?? null,
1395
+ ...(source === 'sent' ? {
1396
+ phoneClock: true,
1397
+ ...(messageId ? {
1398
+ messageId
1399
+ } : {})
1400
+ } : {})
1401
+ };
1402
+ this.lastMessages = Object.fromEntries(Object.entries({
1403
+ ...this.lastMessages,
1404
+ [conversationId]: hint
1405
+ }).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
1406
+ // Until the stored hints are read, saving would overwrite them with these alone.
1407
+ if (this.lastMessagesLoaded) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1408
+ }
1409
+ async loadLastMessages(epoch) {
1410
+ if (this.lastMessagesLoaded) return;
1411
+ let stored = null;
1412
+ try {
1413
+ stored = JSON.parse((await _storage.Storage.getItem(_storage.LAST_MESSAGE_KEY)) ?? 'null');
1414
+ } catch {
1415
+ stored = null;
1416
+ }
1417
+ // Left unloaded: the init that replaces this one reads them again.
1418
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1419
+ this.lastMessagesLoaded = true;
1420
+ const learned = Object.keys(this.lastMessages).length > 0;
1421
+ if (stored && typeof stored === 'object') {
1422
+ for (const [conversationId, hint] of Object.entries(stored)) {
1423
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1424
+ const known = this.lastMessages[conversationId];
1425
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1426
+ this.lastMessages[conversationId] = {
1427
+ at: hint.at,
1428
+ fromVisitor: hint.fromVisitor,
1429
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1430
+ ...(hint.phoneClock === true ? {
1431
+ phoneClock: true
1432
+ } : {}),
1433
+ ...(typeof hint.messageId === 'string' ? {
1434
+ messageId: hint.messageId
1435
+ } : {})
1436
+ };
1437
+ }
1438
+ }
1439
+ // Hints learned while the stored ones were being read are saved with them.
1440
+ if (learned) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1441
+ }
1442
+
1443
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1444
+ learnLastMessage(conversationId, page) {
1445
+ let newest = null;
1446
+ for (const message of page) {
1447
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1448
+ }
1449
+ const fields = newest && rowFieldsOf(newest);
1450
+ if (!newest || !fields) return;
1451
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1452
+ pageIds: new Set(page.map(message => message.id))
1312
1453
  });
1454
+ this.setState(current => ({
1455
+ conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
1456
+ }));
1313
1457
  }
1314
1458
 
1315
1459
  /**
@@ -1318,16 +1462,22 @@ class WidgetStore {
1318
1462
  */
1319
1463
  hydrateAttachments(attachments, messageId) {
1320
1464
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1321
- const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => ({
1322
- ...attachment,
1323
- id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1324
- name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1325
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1326
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1327
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1328
- // then renders the name only, with nothing to open.
1329
- url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1330
- }));
1465
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
1466
+ const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1467
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1468
+ return {
1469
+ ...attachment,
1470
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1471
+ name,
1472
+ // Without a type (or with a generic one) the name says what it is,
1473
+ // so a photo still shows as a photo rather than a file row.
1474
+ contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : (0, _pickers.guessContentType)(name) ?? declared,
1475
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1476
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1477
+ // then renders the name only, with nothing to open.
1478
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1479
+ };
1480
+ });
1331
1481
  return hydrated.length > 0 ? hydrated : null;
1332
1482
  }
1333
1483
  clearTypingTimer() {
@@ -1583,19 +1733,21 @@ class WidgetStore {
1583
1733
  const result = await this.client.listMessages(conversationId);
1584
1734
  // Opening A then B quickly: A's page must never land under B.
1585
1735
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1736
+ const page = this.hydrateMessages(result.data);
1586
1737
  this.setState(current => {
1587
1738
  // Live replies and optimistic sends may have arrived since this page
1588
1739
  // was requested. Keep them, preferring the live copy for shared ids.
1589
- const liveIds = new Set(current.messages.map(message => message.id));
1590
- const page = this.hydrateMessages(result.data);
1740
+ const live = adoptOwnServerRows(current.messages, page);
1741
+ const liveIds = new Set(live.map(message => message.id));
1591
1742
  const history = page.filter(message => !liveIds.has(message.id));
1592
1743
  return {
1593
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1744
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1594
1745
  messagesLoading: false,
1595
1746
  hasMoreMessages: result.meta.hasMore,
1596
1747
  messageCursor: result.meta.nextCursor
1597
1748
  };
1598
1749
  });
1750
+ this.learnLastMessage(conversationId, page);
1599
1751
  } catch {
1600
1752
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1601
1753
  this.setState({
@@ -1657,15 +1809,17 @@ class WidgetStore {
1657
1809
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1658
1810
  if (this.state.activeConversationId !== conversationId) return;
1659
1811
  const fresh = this.hydrateMessages(result.data);
1812
+ this.learnLastMessage(conversationId, fresh);
1660
1813
  this.setState(current => {
1661
- const known = new Set(current.messages.map(message => message.id));
1814
+ const own = adoptOwnServerRows(current.messages, fresh);
1815
+ const known = new Set(own.map(message => message.id));
1662
1816
  const missing = fresh.filter(message => !known.has(message.id));
1663
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1817
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1664
1818
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1665
1819
  messages: upgraded
1666
1820
  };
1667
- const local = current.messages.filter(isLocalMessage);
1668
- const hasServerMessages = local.length < current.messages.length;
1821
+ const local = own.filter(_conversations.isLocalMessage);
1822
+ const hasServerMessages = local.length < own.length;
1669
1823
  // No overlap means the gap is longer than a page: restart from the
1670
1824
  // newest page (keeping unsent bubbles) rather than leave a hole.
1671
1825
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1690,6 +1844,40 @@ class WidgetStore {
1690
1844
  }
1691
1845
  }
1692
1846
 
1847
+ /**
1848
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1849
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1850
+ * open. When the stream's echo hasn't brought that copy, re-read the
1851
+ * thread, and try again a few times while Workstation is still storing it.
1852
+ */
1853
+ async syncServerCopies() {
1854
+ this.cancelServerCopySync();
1855
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1856
+ // A send still in flight would come back as a stored row beside its own
1857
+ // pending bubble. It syncs again when it settles.
1858
+ if (this.state.messages.some(message => (0, _conversations.isLocalMessage)(message) && message.status === 'pending')) return;
1859
+ // Off screen: the thread reloads in full when it's opened again.
1860
+ const conversationId = this.state.activeConversationId;
1861
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1862
+ const generation = this.threadGeneration;
1863
+ this.serverCopySyncing = true;
1864
+ try {
1865
+ // A no-op while a page load is in flight; the retry below covers it.
1866
+ await this.refreshOpenThread();
1867
+ } finally {
1868
+ this.serverCopySyncing = false;
1869
+ }
1870
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1871
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1872
+ if (delay === undefined) return;
1873
+ this.serverCopyRetries += 1;
1874
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1875
+ }
1876
+ cancelServerCopySync() {
1877
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
1878
+ this.serverCopyTimer = null;
1879
+ }
1880
+
1693
1881
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1694
1882
  hydrateMessages(page) {
1695
1883
  return page.map(message => ({
@@ -1737,9 +1925,11 @@ class WidgetStore {
1737
1925
 
1738
1926
  /**
1739
1927
  * Rejects when the message wasn't sent, so the composer can keep
1740
- * attachments for a retry. A text-only bubble is marked failed; one with
1741
- * attachments is removed instead — the composer's chips are the retry, and
1742
- * keeping both would leave a duplicate after a successful resend.
1928
+ * attachments for a retry. A text-only bubble is marked failed and can be
1929
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
1930
+ * composer's chips are the retry, and keeping both would leave a duplicate
1931
+ * after a successful resend. Likewise an agent UI action replaces the
1932
+ * failed bubble of the same action, since its card stays tappable.
1743
1933
  */
1744
1934
  async sendMessage(body, attachments = [], agentUiAction) {
1745
1935
  const trimmed = body.trim();
@@ -1747,7 +1937,6 @@ class WidgetStore {
1747
1937
  const {
1748
1938
  visitorProfile
1749
1939
  } = this.state;
1750
- const generation = this.threadGeneration;
1751
1940
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1752
1941
  const optimistic = {
1753
1942
  id: tempId,
@@ -1771,20 +1960,67 @@ class WidgetStore {
1771
1960
  status: 'pending',
1772
1961
  createdAt: new Date().toISOString()
1773
1962
  };
1963
+
1964
+ // Tapping the card again is the retry: offering the old bubble's retry
1965
+ // too would run the action twice (two items in the cart, two bookings).
1966
+ const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
1967
+ superseded.forEach(messageId => this.failedActions.delete(messageId));
1774
1968
  this.setState(current => ({
1775
- messages: [...current.messages, optimistic],
1969
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1776
1970
  prefillText: ''
1777
1971
  }));
1778
1972
  this.track('widget_message_sent', {
1779
1973
  hasText: !!trimmed,
1780
1974
  attachmentCount: attachments.length
1781
1975
  });
1782
- const markFailed = () => this.setState(current => ({
1783
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1784
- ...message,
1785
- status: 'failed'
1786
- } : message)
1976
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1977
+ }
1978
+
1979
+ /**
1980
+ * Puts a picked file in storage while the visitor is still writing, so
1981
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
1982
+ * when that token expires.
1983
+ */
1984
+ uploadAttachment(file, signal) {
1985
+ return this.client.uploadAttachment(file, signal);
1986
+ }
1987
+
1988
+ /**
1989
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
1990
+ * since it now goes out after anything sent meanwhile. Rejects like
1991
+ * `sendMessage` when it fails again; the bubble is then failed once more.
1992
+ */
1993
+ async retryMessage(messageId) {
1994
+ const failed = this.state.messages.find(message => message.id === messageId && (0, _conversations.canRetryMessage)(message));
1995
+ if (!failed) return;
1996
+ const agentUiAction = this.failedActions.get(messageId);
1997
+ this.failedActions.delete(messageId);
1998
+ const pending = {
1999
+ ...failed,
2000
+ status: 'pending',
2001
+ createdAt: new Date().toISOString()
2002
+ };
2003
+ this.setState(current => ({
2004
+ messages: [...current.messages.filter(message => message.id !== messageId), pending]
1787
2005
  }));
2006
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
2007
+ }
2008
+
2009
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
2010
+ async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
2011
+ const {
2012
+ visitorProfile
2013
+ } = this.state;
2014
+ const generation = this.threadGeneration;
2015
+ const markFailed = () => {
2016
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
2017
+ this.setState(current => ({
2018
+ messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
2019
+ ...message,
2020
+ status: 'failed'
2021
+ } : message)
2022
+ }));
2023
+ };
1788
2024
  const epoch = this.epoch;
1789
2025
 
1790
2026
  // A draft's first send creates the conversation. Anything sent from the
@@ -1810,27 +2046,56 @@ class WidgetStore {
1810
2046
  }
1811
2047
  let createdId = null;
1812
2048
  try {
2049
+ // Local files go up first, unless the composer already put them up; the
2050
+ // message names each by its upload token.
2051
+ const sentAttachments = await Promise.all(attachments.map(async ({
2052
+ name,
2053
+ url,
2054
+ fileUri,
2055
+ uploadToken,
2056
+ contentType,
2057
+ size
2058
+ }) => uploadToken ? {
2059
+ name,
2060
+ contentType,
2061
+ size,
2062
+ uploadToken
2063
+ } : fileUri ? {
2064
+ name,
2065
+ contentType,
2066
+ size,
2067
+ uploadToken: (await this.client.uploadAttachment({
2068
+ uri: fileUri,
2069
+ name,
2070
+ contentType,
2071
+ size
2072
+ })).uploadToken
2073
+ } : {
2074
+ name,
2075
+ url,
2076
+ contentType,
2077
+ size
2078
+ }));
2079
+ if (this.isStale(epoch)) return;
1813
2080
  const result = await this.client.sendMessage({
1814
2081
  conversationId: conversationId || undefined,
1815
2082
  forceNewConversation: !conversationId,
1816
2083
  body: trimmed,
1817
- attachments: attachments.length > 0 ? attachments.map(({
1818
- name,
1819
- url,
1820
- contentType,
1821
- size
1822
- }) => ({
1823
- name,
1824
- url,
1825
- contentType,
1826
- size
1827
- })) : undefined,
2084
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1828
2085
  visitorName: visitorProfile.name,
1829
2086
  visitorEmail: visitorProfile.email,
1830
- agentUiAction
2087
+ agentUiAction,
2088
+ // The bubble's id, the same on every retry: a newer Workstation stores
2089
+ // the message once even when an earlier attempt's response was lost.
2090
+ clientMessageId: tempId
1831
2091
  });
1832
2092
  if (this.isStale(epoch)) return;
1833
2093
  if (ownCreation) createdId = result.conversationId;
2094
+
2095
+ // Workstation doesn't stream the visitor's own messages back, so the
2096
+ // list row learns about this one here.
2097
+ const sentAt = new Date().toISOString();
2098
+ const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
1834
2099
  this.setState(current => {
1835
2100
  // The stream or a catch-up fetch may have delivered this message
1836
2101
  // already — keep one copy, never two bubbles with the same id.
@@ -1840,18 +2105,27 @@ class WidgetStore {
1840
2105
  id: result.messageId,
1841
2106
  status: this.earlyStatuses.get(result.messageId) ?? 'sent'
1842
2107
  } : message);
1843
- if (!createdId) return {
1844
- messages
1845
- };
2108
+ if (!createdId) {
2109
+ return {
2110
+ messages,
2111
+ conversations: current.conversations.map(row => row.id === result.conversationId ? {
2112
+ ...row,
2113
+ ...sentRow,
2114
+ lastMessageAt: sentAt,
2115
+ messageCount: row.messageCount + (delivered ? 0 : 1)
2116
+ } : row)
2117
+ };
2118
+ }
1846
2119
  const known = current.conversations.some(item => item.id === result.conversationId);
1847
2120
  const conversation = {
1848
2121
  id: result.conversationId,
1849
2122
  subject: null,
1850
2123
  status: 'open',
1851
- snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2124
+ snippet: null,
2125
+ ...sentRow,
1852
2126
  messageCount: 1,
1853
- lastMessageAt: new Date().toISOString(),
1854
- createdAt: new Date().toISOString(),
2127
+ lastMessageAt: sentAt,
2128
+ createdAt: sentAt,
1855
2129
  agentName: null,
1856
2130
  agentAvatar: null
1857
2131
  };
@@ -1864,8 +2138,18 @@ class WidgetStore {
1864
2138
  } : {})
1865
2139
  };
1866
2140
  });
2141
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
2142
+ messageId: result.messageId
2143
+ });
2144
+
2145
+ // New files to fetch the stored copy of: a fresh round of re-reads.
2146
+ const sent = this.state.messages.find(message => message.id === result.messageId);
2147
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1867
2148
  } catch (error) {
1868
2149
  if (this.isStale(epoch)) throw error;
2150
+ // The response was lost, but its stored row has already taken the
2151
+ // bubble's place (newer Workstation deployments): it was sent.
2152
+ if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
1869
2153
  markFailed();
1870
2154
  throw error;
1871
2155
  } finally {
@@ -1876,6 +2160,8 @@ class WidgetStore {
1876
2160
  // Anything held back for the draft now has a home (or goes to unread).
1877
2161
  this.replayDraftEvents();
1878
2162
  }
2163
+ // Waits for every send to settle, so this runs when the last one does.
2164
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1879
2165
  }
1880
2166
  }
1881
2167
  async identifyVisitor(data) {
@@ -2350,11 +2636,6 @@ function createConversationCreation(generation) {
2350
2636
  };
2351
2637
  }
2352
2638
 
2353
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2354
- function isLocalMessage(message) {
2355
- return message.id.startsWith('temp-');
2356
- }
2357
-
2358
2639
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2359
2640
  function adoptServerStatuses(messages, server) {
2360
2641
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2383,6 +2664,65 @@ function asDeletedPlaceholder(message, deletedAt) {
2383
2664
  };
2384
2665
  }
2385
2666
 
2667
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2668
+ function plainTextOf(message) {
2669
+ return (message.bodyPlain || (0, _html.stripHtml)(message.body ?? '')).replace(/\s+/g, ' ').trim();
2670
+ }
2671
+
2672
+ /**
2673
+ * The newest message of a thread as this device saw it. `at` is its
2674
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2675
+ * message it's the phone's clock instead.
2676
+ */
2677
+
2678
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2679
+
2680
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2681
+ function rowFieldsOf(message) {
2682
+ if (message.messageType && message.messageType !== 'message') return null;
2683
+ return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
2684
+ }
2685
+
2686
+ /**
2687
+ * Its words, or with none, whether it carried only photos or other files too.
2688
+ * A message with neither (a card) still says who sent it, so a teammate's
2689
+ * card never leaves "You:" on the visitor's earlier message.
2690
+ */
2691
+ function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
2692
+ if (text) return {
2693
+ snippet: text,
2694
+ lastMessageAttachment: null,
2695
+ lastMessageFromVisitor: fromVisitor
2696
+ };
2697
+ const count = contentTypes.length;
2698
+ if (count === 0 && !inlinePhoto) return {
2699
+ lastMessageAttachment: null,
2700
+ lastMessageFromVisitor: fromVisitor
2701
+ };
2702
+ return {
2703
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2704
+ ...(count > 0 ? {
2705
+ snippet: `${count} attachment${count === 1 ? '' : 's'}`
2706
+ } : {}),
2707
+ lastMessageAttachment: contentTypes.every(type => (0, _pickers.isImageAttachment)(type)) ? 'photo' : 'file',
2708
+ lastMessageFromVisitor: fromVisitor
2709
+ };
2710
+ }
2711
+
2712
+ /**
2713
+ * The list stamps a conversation just after storing its message, so its time
2714
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2715
+ * A sent message is timed by the phone instead: a little drift either way
2716
+ * still matches, but only when the list rules out a team reply the visitor
2717
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2718
+ * there a sent message must match like any other.
2719
+ */
2720
+ function describesSameMessage(conversation, known) {
2721
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2722
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2723
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2724
+ }
2725
+
2386
2726
  /** A deletion missed while the stream was down shows up on the next re-read. */
2387
2727
  function adoptServerDeletions(messages, server) {
2388
2728
  const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
@@ -2408,17 +2748,100 @@ function adoptServerAttachments(messages, server) {
2408
2748
  let changed = false;
2409
2749
  const next = messages.map(message => {
2410
2750
  const copy = serverCopies.get(message.id);
2411
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2412
- if (!copy || !hasLocal) return message;
2751
+ if (!copy || !hasLocalAttachments(message)) return message;
2752
+ const attachments = mergeAttachments(message.attachments, copy.attachments);
2753
+ if (attachments === message.attachments) return message;
2413
2754
  changed = true;
2414
2755
  return {
2415
2756
  ...message,
2416
- attachments: copy.attachments
2757
+ attachments
2417
2758
  };
2418
2759
  });
2419
2760
  return changed ? next : messages;
2420
2761
  }
2421
2762
 
2763
+ /**
2764
+ * Each attachment's server copy, except one without a usable URL: that would
2765
+ * only blank its local preview, so the preview stays until a later copy has it.
2766
+ * Returns `local` itself when nothing changes.
2767
+ */
2768
+ function mergeAttachments(local, server) {
2769
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2770
+ if (local.length !== server.length) {
2771
+ return server.every(attachment => attachment.url) ? server : local;
2772
+ }
2773
+ const owns = pairByName(local, server);
2774
+ const merged = server.map((attachment, index) => {
2775
+ const own = owns[index];
2776
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2777
+ (0, _localPreviews.rememberLocalPreview)(attachment.url, own.url);
2778
+ return attachment;
2779
+ });
2780
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2781
+ }
2782
+
2783
+ /**
2784
+ * The local attachment each server one replaces. By name first: Workstation
2785
+ * may store a message's files in another order than they were sent, and a
2786
+ * photo paired with the wrong file would show it for the rest of the session.
2787
+ * Any it renamed pair up in the order left.
2788
+ */
2789
+ function pairByName(local, server) {
2790
+ const unpaired = [...local];
2791
+ const named = server.map(attachment => {
2792
+ const index = unpaired.findIndex(own => own.name === attachment.name);
2793
+ return index < 0 ? null : unpaired.splice(index, 1)[0];
2794
+ });
2795
+ return named.map(own => own ?? unpaired.shift());
2796
+ }
2797
+
2798
+ /**
2799
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2800
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2801
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2802
+ * for a message that was stored although its response was lost.
2803
+ */
2804
+ function adoptOwnServerRows(messages, server) {
2805
+ const rows = new Map();
2806
+ server.forEach(row => {
2807
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2808
+ });
2809
+ if (rows.size === 0) return messages;
2810
+ const shown = new Set(messages.map(message => message.id));
2811
+ let changed = false;
2812
+ const next = [];
2813
+ messages.forEach(message => {
2814
+ const row = (0, _conversations.isLocalMessage)(message) ? rows.get(message.id) : undefined;
2815
+ if (!row) {
2816
+ next.push(message);
2817
+ return;
2818
+ }
2819
+ changed = true;
2820
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2821
+ if (shown.has(row.id)) return;
2822
+ next.push({
2823
+ ...row,
2824
+ attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
2825
+ });
2826
+ });
2827
+ return changed ? next : messages;
2828
+ }
2829
+
2830
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2831
+ function isLocalAttachment(attachment) {
2832
+ return !/^https?:\/\//i.test(attachment.url);
2833
+ }
2834
+
2835
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2836
+ function hasLocalAttachments(message) {
2837
+ return !!message.attachments?.some(isLocalAttachment);
2838
+ }
2839
+
2840
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2841
+ function isAwaitingServerCopy(message) {
2842
+ return !(0, _conversations.isLocalMessage)(message) && hasLocalAttachments(message);
2843
+ }
2844
+
2422
2845
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2423
2846
  function sortByCreatedAt(messages) {
2424
2847
  const time = message => Date.parse(message.createdAt) || 0;