@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
@@ -16,13 +16,17 @@ import { WidgetRealtime } from "../api/realtime.js";
16
16
  import { getDeviceId, rotateDeviceId } from "../device-id.js";
17
17
  import { EventEmitter, RepzoWidgetEvents } from "../events.js";
18
18
  import { PushManager } from "../push/push-notifications.js";
19
- import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
20
- import { setAttachmentOptions } from "../attachments/pickers.js";
19
+ import { CONFIG_KEY, LAST_MESSAGE_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
20
+ import { clearLocalPreviews, rememberLocalPreview } from "../attachments/local-previews.js";
21
+ import { guessContentType, isImageAttachment, setAttachmentOptions } from "../attachments/pickers.js";
21
22
  import { DEFAULT_BASE_URL } from "../types.js";
23
+ import { clearRetainedState } from "../ui/hooks/use-retained-state.js";
24
+ import { canRetryMessage, isLocalMessage } from "../ui/utils/conversations.js";
25
+ import { stripHtml } from "../ui/utils/html.js";
22
26
  import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
23
27
 
24
28
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
25
- const SDK_VERSION = '@repzo/widget-react-native@1.3.0';
29
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
26
30
 
27
31
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
28
32
  function sessionPlatform() {
@@ -37,6 +41,12 @@ const TYPING_SETTLE_MS = 3_000;
37
41
  const TYPING_PING_INTERVAL_MS = 4_000;
38
42
  /** Guards against the SSE stream replaying messages after a reconnect. */
39
43
  const SEEN_MESSAGE_LIMIT = 100;
44
+ /** Threads whose newest message is remembered across launches. */
45
+ const LAST_MESSAGE_LIMIT = 50;
46
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
47
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
48
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
49
+ const LIST_LAG_MS = 1_000;
40
50
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
41
51
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
42
52
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -52,6 +62,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
52
62
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
53
63
  /** Live events held back while a draft's first send is creating its thread. */
54
64
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
65
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
66
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
55
67
  /** How long a post stays up after a reaction, thanking them, before it goes. */
56
68
  export const POST_THANKS_MS = 1600;
57
69
 
@@ -128,6 +140,10 @@ export class WidgetStore {
128
140
 
129
141
  /** `boot({ posts })` — Send posts over the app. */
130
142
  postsEnabled = true;
143
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
144
+ photoAlbumsEnabled = true;
145
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
146
+ youPrefixEnabled = true;
131
147
 
132
148
  /** Posts whose view was reported this session (the server also dedupes per day). */
133
149
  viewedPostIds = new Set();
@@ -182,8 +198,18 @@ export class WidgetStore {
182
198
  lastTypingPingAt = 0;
183
199
  visitorTypingActive = false;
184
200
  seenMessageIds = [];
201
+
202
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
203
+ lastMessages = {};
204
+ lastMessagesLoaded = false;
185
205
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
186
206
  earlyStatuses = new Map();
207
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
208
+ failedActions = new Map();
209
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
210
+ serverCopyTimer = null;
211
+ serverCopyRetries = 0;
212
+ serverCopySyncing = false;
187
213
 
188
214
  /**
189
215
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -255,6 +281,8 @@ export class WidgetStore {
255
281
 
256
282
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
257
283
  getThreadGeneration = () => this.threadGeneration;
284
+ showsPhotoAlbums = () => this.photoAlbumsEnabled;
285
+ showsYouPrefix = () => this.youPrefixEnabled;
258
286
  setState(patch) {
259
287
  const previous = this.state;
260
288
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -326,6 +354,8 @@ export class WidgetStore {
326
354
  if (options.inAppNotifications !== undefined) {
327
355
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
328
356
  }
357
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
358
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
329
359
  if (options.posts !== undefined) {
330
360
  const wasEnabled = this.postsEnabled;
331
361
  this.postsEnabled = options.posts !== false;
@@ -480,13 +510,13 @@ export class WidgetStore {
480
510
  // queued op must never see one without the other.
481
511
  await Promise.all([this.client.setToken(result.token), userKey ? SecureStorage.setItem(USER_KEY, userKey) : SecureStorage.removeItem(USER_KEY)]);
482
512
  if (this.isStale(epoch)) return;
483
- const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
484
513
 
485
514
  // Server counts win (they include replies pushed while the app was
486
515
  // killed); the locally persisted map only covers older Workstation
487
516
  // deployments that don't send unreadCount yet.
488
- const storedUnread = await this.loadUnread();
517
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
489
518
  if (this.isStale(epoch)) return;
519
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
490
520
  const unreadByConversation = {};
491
521
  for (const conversation of conversations) {
492
522
  const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
@@ -777,16 +807,20 @@ export class WidgetStore {
777
807
  this.userKeyLoaded = true;
778
808
  this.pendingAgentByConversation = {};
779
809
  this.seenMessageIds = [];
810
+ this.lastMessages = {};
811
+ this.lastMessagesLoaded = false;
780
812
  this.earlyStatuses.clear();
781
813
  this.visitorTypingActive = false;
782
814
  this.lastTypingPingAt = 0;
783
815
  this.launchCardOffered = false;
784
816
  this.resetPost();
817
+ clearLocalPreviews();
818
+ clearRetainedState();
785
819
  this.setState({
786
820
  ...USER_SCOPED_STATE
787
821
  });
788
822
  const token = await this.client.getToken();
789
- await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY),
823
+ await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY), Storage.removeItem(LAST_MESSAGE_KEY),
790
824
  // A new anonymous visitor on this device gets a new tracking identity.
791
825
  rotateDeviceId()]);
792
826
 
@@ -822,7 +856,9 @@ export class WidgetStore {
822
856
  this.threadGeneration += 1;
823
857
  this.conversationCreation = null;
824
858
  this.draftEvents = [];
859
+ this.failedActions.clear();
825
860
  this.cancelMarkRead();
861
+ this.cancelServerCopySync();
826
862
  }
827
863
  requireBootConfig() {
828
864
  if (!this.bootConfig) {
@@ -1138,7 +1174,10 @@ export class WidgetStore {
1138
1174
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1139
1175
  metadata: this.hydrateMetadata(data?.metadata || null),
1140
1176
  status: 'delivered',
1141
- createdAt: data?.createdAt || new Date().toISOString()
1177
+ createdAt: data?.createdAt || new Date().toISOString(),
1178
+ ...(typeof data?.clientMessageId === 'string' ? {
1179
+ clientMessageId: data.clientMessageId
1180
+ } : {})
1142
1181
  };
1143
1182
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1144
1183
  if (clearsTyping) this.clearTypingTimer();
@@ -1161,18 +1200,20 @@ export class WidgetStore {
1161
1200
 
1162
1201
  // Set inside the updater, read after: `setState` runs synchronously.
1163
1202
  let countedAsUnread = false;
1203
+ const rowFields = rowFieldsOf(message);
1164
1204
  this.setState(current => {
1165
1205
  const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
1166
1206
  ...conversation,
1167
- snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
1207
+ ...rowFields,
1168
1208
  lastMessageAt: message.createdAt,
1169
1209
  messageCount: conversation.messageCount + 1
1170
1210
  } : conversation) : current.conversations;
1171
1211
  if (onScreen) {
1172
- const alreadyPresent = current.messages.some(existing => existing.id === message.id);
1212
+ const shown = adoptOwnServerRows(current.messages, [message]);
1213
+ const alreadyPresent = shown.some(existing => existing.id === message.id);
1173
1214
  return {
1174
1215
  conversations,
1175
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1216
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1176
1217
  agentTyping: clearsTyping ? false : current.agentTyping
1177
1218
  };
1178
1219
  }
@@ -1201,11 +1242,12 @@ export class WidgetStore {
1201
1242
  messageId,
1202
1243
  senderName: message.senderName || conversation?.agentName || null,
1203
1244
  senderAvatar: conversation?.agentAvatar ?? null,
1204
- preview: (message.bodyPlain ?? '').trim(),
1245
+ preview: plainTextOf(message),
1205
1246
  hasAttachments: (message.attachments?.length ?? 0) > 0
1206
1247
  }
1207
1248
  };
1208
1249
  });
1250
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1209
1251
 
1210
1252
  // The visitor is reading it live — move the server cursor too, or the
1211
1253
  // next list/init would badge a reply they already saw.
@@ -1303,10 +1345,112 @@ export class WidgetStore {
1303
1345
  };
1304
1346
  }
1305
1347
  hydrateConversation(conversation) {
1306
- return this.applyConversationAgent({
1348
+ return this.applyConversationAgent(this.withLastMessage({
1307
1349
  ...conversation,
1308
1350
  agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1351
+ }));
1352
+ }
1353
+
1354
+ /**
1355
+ * Workstation's list doesn't say who sent the newest message, and calls one
1356
+ * without words "2 attachments". While a row still describes the message
1357
+ * this device saw last, both are filled in from it. `snippet` keeps the
1358
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1359
+ * `lastMessageAttachment` instead.
1360
+ */
1361
+ withLastMessage(conversation) {
1362
+ const known = this.lastMessages[conversation.id];
1363
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1364
+ // A list that says who sent it, or what it carried, is used as is.
1365
+ return {
1366
+ ...conversation,
1367
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1368
+ lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
1369
+ };
1370
+ }
1371
+
1372
+ /**
1373
+ * A send or a streamed message is the newest by arrival, whatever the two
1374
+ * clocks say; a history page only replaces something older. A sent message
1375
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1376
+ * page that has it replaces it: whatever is newest there came after it, and
1377
+ * Workstation's own time then stands in for the phone's.
1378
+ */
1379
+ rememberLastMessage(conversationId, at, fields, source, {
1380
+ messageId,
1381
+ pageIds
1382
+ } = {}) {
1383
+ const known = this.lastMessages[conversationId];
1384
+ if (source === 'history' && known) {
1385
+ const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
1386
+ if (outdated) return;
1387
+ }
1388
+ const hint = {
1389
+ at,
1390
+ fromVisitor: !!fields.lastMessageFromVisitor,
1391
+ attachment: fields.lastMessageAttachment ?? null,
1392
+ ...(source === 'sent' ? {
1393
+ phoneClock: true,
1394
+ ...(messageId ? {
1395
+ messageId
1396
+ } : {})
1397
+ } : {})
1398
+ };
1399
+ this.lastMessages = Object.fromEntries(Object.entries({
1400
+ ...this.lastMessages,
1401
+ [conversationId]: hint
1402
+ }).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
1403
+ // Until the stored hints are read, saving would overwrite them with these alone.
1404
+ if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1405
+ }
1406
+ async loadLastMessages(epoch) {
1407
+ if (this.lastMessagesLoaded) return;
1408
+ let stored = null;
1409
+ try {
1410
+ stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
1411
+ } catch {
1412
+ stored = null;
1413
+ }
1414
+ // Left unloaded: the init that replaces this one reads them again.
1415
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1416
+ this.lastMessagesLoaded = true;
1417
+ const learned = Object.keys(this.lastMessages).length > 0;
1418
+ if (stored && typeof stored === 'object') {
1419
+ for (const [conversationId, hint] of Object.entries(stored)) {
1420
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1421
+ const known = this.lastMessages[conversationId];
1422
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1423
+ this.lastMessages[conversationId] = {
1424
+ at: hint.at,
1425
+ fromVisitor: hint.fromVisitor,
1426
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1427
+ ...(hint.phoneClock === true ? {
1428
+ phoneClock: true
1429
+ } : {}),
1430
+ ...(typeof hint.messageId === 'string' ? {
1431
+ messageId: hint.messageId
1432
+ } : {})
1433
+ };
1434
+ }
1435
+ }
1436
+ // Hints learned while the stored ones were being read are saved with them.
1437
+ if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1438
+ }
1439
+
1440
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1441
+ learnLastMessage(conversationId, page) {
1442
+ let newest = null;
1443
+ for (const message of page) {
1444
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1445
+ }
1446
+ const fields = newest && rowFieldsOf(newest);
1447
+ if (!newest || !fields) return;
1448
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1449
+ pageIds: new Set(page.map(message => message.id))
1309
1450
  });
1451
+ this.setState(current => ({
1452
+ conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
1453
+ }));
1310
1454
  }
1311
1455
 
1312
1456
  /**
@@ -1315,16 +1459,22 @@ export class WidgetStore {
1315
1459
  */
1316
1460
  hydrateAttachments(attachments, messageId) {
1317
1461
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1318
- const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => ({
1319
- ...attachment,
1320
- id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1321
- name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1322
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1323
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1324
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1325
- // then renders the name only, with nothing to open.
1326
- url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1327
- }));
1462
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
1463
+ const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1464
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1465
+ return {
1466
+ ...attachment,
1467
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1468
+ name,
1469
+ // Without a type (or with a generic one) the name says what it is,
1470
+ // so a photo still shows as a photo rather than a file row.
1471
+ contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : guessContentType(name) ?? declared,
1472
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1473
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1474
+ // then renders the name only, with nothing to open.
1475
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1476
+ };
1477
+ });
1328
1478
  return hydrated.length > 0 ? hydrated : null;
1329
1479
  }
1330
1480
  clearTypingTimer() {
@@ -1580,19 +1730,21 @@ export class WidgetStore {
1580
1730
  const result = await this.client.listMessages(conversationId);
1581
1731
  // Opening A then B quickly: A's page must never land under B.
1582
1732
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1733
+ const page = this.hydrateMessages(result.data);
1583
1734
  this.setState(current => {
1584
1735
  // Live replies and optimistic sends may have arrived since this page
1585
1736
  // was requested. Keep them, preferring the live copy for shared ids.
1586
- const liveIds = new Set(current.messages.map(message => message.id));
1587
- const page = this.hydrateMessages(result.data);
1737
+ const live = adoptOwnServerRows(current.messages, page);
1738
+ const liveIds = new Set(live.map(message => message.id));
1588
1739
  const history = page.filter(message => !liveIds.has(message.id));
1589
1740
  return {
1590
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1741
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1591
1742
  messagesLoading: false,
1592
1743
  hasMoreMessages: result.meta.hasMore,
1593
1744
  messageCursor: result.meta.nextCursor
1594
1745
  };
1595
1746
  });
1747
+ this.learnLastMessage(conversationId, page);
1596
1748
  } catch {
1597
1749
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1598
1750
  this.setState({
@@ -1654,15 +1806,17 @@ export class WidgetStore {
1654
1806
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1655
1807
  if (this.state.activeConversationId !== conversationId) return;
1656
1808
  const fresh = this.hydrateMessages(result.data);
1809
+ this.learnLastMessage(conversationId, fresh);
1657
1810
  this.setState(current => {
1658
- const known = new Set(current.messages.map(message => message.id));
1811
+ const own = adoptOwnServerRows(current.messages, fresh);
1812
+ const known = new Set(own.map(message => message.id));
1659
1813
  const missing = fresh.filter(message => !known.has(message.id));
1660
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1814
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1661
1815
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1662
1816
  messages: upgraded
1663
1817
  };
1664
- const local = current.messages.filter(isLocalMessage);
1665
- const hasServerMessages = local.length < current.messages.length;
1818
+ const local = own.filter(isLocalMessage);
1819
+ const hasServerMessages = local.length < own.length;
1666
1820
  // No overlap means the gap is longer than a page: restart from the
1667
1821
  // newest page (keeping unsent bubbles) rather than leave a hole.
1668
1822
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1687,6 +1841,40 @@ export class WidgetStore {
1687
1841
  }
1688
1842
  }
1689
1843
 
1844
+ /**
1845
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1846
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1847
+ * open. When the stream's echo hasn't brought that copy, re-read the
1848
+ * thread, and try again a few times while Workstation is still storing it.
1849
+ */
1850
+ async syncServerCopies() {
1851
+ this.cancelServerCopySync();
1852
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1853
+ // A send still in flight would come back as a stored row beside its own
1854
+ // pending bubble. It syncs again when it settles.
1855
+ if (this.state.messages.some(message => isLocalMessage(message) && message.status === 'pending')) return;
1856
+ // Off screen: the thread reloads in full when it's opened again.
1857
+ const conversationId = this.state.activeConversationId;
1858
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1859
+ const generation = this.threadGeneration;
1860
+ this.serverCopySyncing = true;
1861
+ try {
1862
+ // A no-op while a page load is in flight; the retry below covers it.
1863
+ await this.refreshOpenThread();
1864
+ } finally {
1865
+ this.serverCopySyncing = false;
1866
+ }
1867
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1868
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1869
+ if (delay === undefined) return;
1870
+ this.serverCopyRetries += 1;
1871
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1872
+ }
1873
+ cancelServerCopySync() {
1874
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
1875
+ this.serverCopyTimer = null;
1876
+ }
1877
+
1690
1878
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1691
1879
  hydrateMessages(page) {
1692
1880
  return page.map(message => ({
@@ -1734,9 +1922,11 @@ export class WidgetStore {
1734
1922
 
1735
1923
  /**
1736
1924
  * Rejects when the message wasn't sent, so the composer can keep
1737
- * attachments for a retry. A text-only bubble is marked failed; one with
1738
- * attachments is removed instead — the composer's chips are the retry, and
1739
- * keeping both would leave a duplicate after a successful resend.
1925
+ * attachments for a retry. A text-only bubble is marked failed and can be
1926
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
1927
+ * composer's chips are the retry, and keeping both would leave a duplicate
1928
+ * after a successful resend. Likewise an agent UI action replaces the
1929
+ * failed bubble of the same action, since its card stays tappable.
1740
1930
  */
1741
1931
  async sendMessage(body, attachments = [], agentUiAction) {
1742
1932
  const trimmed = body.trim();
@@ -1744,7 +1934,6 @@ export class WidgetStore {
1744
1934
  const {
1745
1935
  visitorProfile
1746
1936
  } = this.state;
1747
- const generation = this.threadGeneration;
1748
1937
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1749
1938
  const optimistic = {
1750
1939
  id: tempId,
@@ -1768,20 +1957,67 @@ export class WidgetStore {
1768
1957
  status: 'pending',
1769
1958
  createdAt: new Date().toISOString()
1770
1959
  };
1960
+
1961
+ // Tapping the card again is the retry: offering the old bubble's retry
1962
+ // too would run the action twice (two items in the cart, two bookings).
1963
+ const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
1964
+ superseded.forEach(messageId => this.failedActions.delete(messageId));
1771
1965
  this.setState(current => ({
1772
- messages: [...current.messages, optimistic],
1966
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1773
1967
  prefillText: ''
1774
1968
  }));
1775
1969
  this.track('widget_message_sent', {
1776
1970
  hasText: !!trimmed,
1777
1971
  attachmentCount: attachments.length
1778
1972
  });
1779
- const markFailed = () => this.setState(current => ({
1780
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1781
- ...message,
1782
- status: 'failed'
1783
- } : message)
1973
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1974
+ }
1975
+
1976
+ /**
1977
+ * Puts a picked file in storage while the visitor is still writing, so
1978
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
1979
+ * when that token expires.
1980
+ */
1981
+ uploadAttachment(file, signal) {
1982
+ return this.client.uploadAttachment(file, signal);
1983
+ }
1984
+
1985
+ /**
1986
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
1987
+ * since it now goes out after anything sent meanwhile. Rejects like
1988
+ * `sendMessage` when it fails again; the bubble is then failed once more.
1989
+ */
1990
+ async retryMessage(messageId) {
1991
+ const failed = this.state.messages.find(message => message.id === messageId && canRetryMessage(message));
1992
+ if (!failed) return;
1993
+ const agentUiAction = this.failedActions.get(messageId);
1994
+ this.failedActions.delete(messageId);
1995
+ const pending = {
1996
+ ...failed,
1997
+ status: 'pending',
1998
+ createdAt: new Date().toISOString()
1999
+ };
2000
+ this.setState(current => ({
2001
+ messages: [...current.messages.filter(message => message.id !== messageId), pending]
1784
2002
  }));
2003
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
2004
+ }
2005
+
2006
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
2007
+ async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
2008
+ const {
2009
+ visitorProfile
2010
+ } = this.state;
2011
+ const generation = this.threadGeneration;
2012
+ const markFailed = () => {
2013
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
2014
+ this.setState(current => ({
2015
+ messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
2016
+ ...message,
2017
+ status: 'failed'
2018
+ } : message)
2019
+ }));
2020
+ };
1785
2021
  const epoch = this.epoch;
1786
2022
 
1787
2023
  // A draft's first send creates the conversation. Anything sent from the
@@ -1807,27 +2043,56 @@ export class WidgetStore {
1807
2043
  }
1808
2044
  let createdId = null;
1809
2045
  try {
2046
+ // Local files go up first, unless the composer already put them up; the
2047
+ // message names each by its upload token.
2048
+ const sentAttachments = await Promise.all(attachments.map(async ({
2049
+ name,
2050
+ url,
2051
+ fileUri,
2052
+ uploadToken,
2053
+ contentType,
2054
+ size
2055
+ }) => uploadToken ? {
2056
+ name,
2057
+ contentType,
2058
+ size,
2059
+ uploadToken
2060
+ } : fileUri ? {
2061
+ name,
2062
+ contentType,
2063
+ size,
2064
+ uploadToken: (await this.client.uploadAttachment({
2065
+ uri: fileUri,
2066
+ name,
2067
+ contentType,
2068
+ size
2069
+ })).uploadToken
2070
+ } : {
2071
+ name,
2072
+ url,
2073
+ contentType,
2074
+ size
2075
+ }));
2076
+ if (this.isStale(epoch)) return;
1810
2077
  const result = await this.client.sendMessage({
1811
2078
  conversationId: conversationId || undefined,
1812
2079
  forceNewConversation: !conversationId,
1813
2080
  body: trimmed,
1814
- attachments: attachments.length > 0 ? attachments.map(({
1815
- name,
1816
- url,
1817
- contentType,
1818
- size
1819
- }) => ({
1820
- name,
1821
- url,
1822
- contentType,
1823
- size
1824
- })) : undefined,
2081
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1825
2082
  visitorName: visitorProfile.name,
1826
2083
  visitorEmail: visitorProfile.email,
1827
- agentUiAction
2084
+ agentUiAction,
2085
+ // The bubble's id, the same on every retry: a newer Workstation stores
2086
+ // the message once even when an earlier attempt's response was lost.
2087
+ clientMessageId: tempId
1828
2088
  });
1829
2089
  if (this.isStale(epoch)) return;
1830
2090
  if (ownCreation) createdId = result.conversationId;
2091
+
2092
+ // Workstation doesn't stream the visitor's own messages back, so the
2093
+ // list row learns about this one here.
2094
+ const sentAt = new Date().toISOString();
2095
+ const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
1831
2096
  this.setState(current => {
1832
2097
  // The stream or a catch-up fetch may have delivered this message
1833
2098
  // already — keep one copy, never two bubbles with the same id.
@@ -1837,18 +2102,27 @@ export class WidgetStore {
1837
2102
  id: result.messageId,
1838
2103
  status: this.earlyStatuses.get(result.messageId) ?? 'sent'
1839
2104
  } : message);
1840
- if (!createdId) return {
1841
- messages
1842
- };
2105
+ if (!createdId) {
2106
+ return {
2107
+ messages,
2108
+ conversations: current.conversations.map(row => row.id === result.conversationId ? {
2109
+ ...row,
2110
+ ...sentRow,
2111
+ lastMessageAt: sentAt,
2112
+ messageCount: row.messageCount + (delivered ? 0 : 1)
2113
+ } : row)
2114
+ };
2115
+ }
1843
2116
  const known = current.conversations.some(item => item.id === result.conversationId);
1844
2117
  const conversation = {
1845
2118
  id: result.conversationId,
1846
2119
  subject: null,
1847
2120
  status: 'open',
1848
- snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2121
+ snippet: null,
2122
+ ...sentRow,
1849
2123
  messageCount: 1,
1850
- lastMessageAt: new Date().toISOString(),
1851
- createdAt: new Date().toISOString(),
2124
+ lastMessageAt: sentAt,
2125
+ createdAt: sentAt,
1852
2126
  agentName: null,
1853
2127
  agentAvatar: null
1854
2128
  };
@@ -1861,8 +2135,18 @@ export class WidgetStore {
1861
2135
  } : {})
1862
2136
  };
1863
2137
  });
2138
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
2139
+ messageId: result.messageId
2140
+ });
2141
+
2142
+ // New files to fetch the stored copy of: a fresh round of re-reads.
2143
+ const sent = this.state.messages.find(message => message.id === result.messageId);
2144
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1864
2145
  } catch (error) {
1865
2146
  if (this.isStale(epoch)) throw error;
2147
+ // The response was lost, but its stored row has already taken the
2148
+ // bubble's place (newer Workstation deployments): it was sent.
2149
+ if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
1866
2150
  markFailed();
1867
2151
  throw error;
1868
2152
  } finally {
@@ -1873,6 +2157,8 @@ export class WidgetStore {
1873
2157
  // Anything held back for the draft now has a home (or goes to unread).
1874
2158
  this.replayDraftEvents();
1875
2159
  }
2160
+ // Waits for every send to settle, so this runs when the last one does.
2161
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1876
2162
  }
1877
2163
  }
1878
2164
  async identifyVisitor(data) {
@@ -2346,11 +2632,6 @@ function createConversationCreation(generation) {
2346
2632
  };
2347
2633
  }
2348
2634
 
2349
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2350
- function isLocalMessage(message) {
2351
- return message.id.startsWith('temp-');
2352
- }
2353
-
2354
2635
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2355
2636
  function adoptServerStatuses(messages, server) {
2356
2637
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2379,6 +2660,65 @@ function asDeletedPlaceholder(message, deletedAt) {
2379
2660
  };
2380
2661
  }
2381
2662
 
2663
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2664
+ function plainTextOf(message) {
2665
+ return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
2666
+ }
2667
+
2668
+ /**
2669
+ * The newest message of a thread as this device saw it. `at` is its
2670
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2671
+ * message it's the phone's clock instead.
2672
+ */
2673
+
2674
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2675
+
2676
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2677
+ function rowFieldsOf(message) {
2678
+ if (message.messageType && message.messageType !== 'message') return null;
2679
+ return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
2680
+ }
2681
+
2682
+ /**
2683
+ * Its words, or with none, whether it carried only photos or other files too.
2684
+ * A message with neither (a card) still says who sent it, so a teammate's
2685
+ * card never leaves "You:" on the visitor's earlier message.
2686
+ */
2687
+ function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
2688
+ if (text) return {
2689
+ snippet: text,
2690
+ lastMessageAttachment: null,
2691
+ lastMessageFromVisitor: fromVisitor
2692
+ };
2693
+ const count = contentTypes.length;
2694
+ if (count === 0 && !inlinePhoto) return {
2695
+ lastMessageAttachment: null,
2696
+ lastMessageFromVisitor: fromVisitor
2697
+ };
2698
+ return {
2699
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2700
+ ...(count > 0 ? {
2701
+ snippet: `${count} attachment${count === 1 ? '' : 's'}`
2702
+ } : {}),
2703
+ lastMessageAttachment: contentTypes.every(type => isImageAttachment(type)) ? 'photo' : 'file',
2704
+ lastMessageFromVisitor: fromVisitor
2705
+ };
2706
+ }
2707
+
2708
+ /**
2709
+ * The list stamps a conversation just after storing its message, so its time
2710
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2711
+ * A sent message is timed by the phone instead: a little drift either way
2712
+ * still matches, but only when the list rules out a team reply the visitor
2713
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2714
+ * there a sent message must match like any other.
2715
+ */
2716
+ function describesSameMessage(conversation, known) {
2717
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2718
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2719
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2720
+ }
2721
+
2382
2722
  /** A deletion missed while the stream was down shows up on the next re-read. */
2383
2723
  function adoptServerDeletions(messages, server) {
2384
2724
  const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
@@ -2404,17 +2744,100 @@ function adoptServerAttachments(messages, server) {
2404
2744
  let changed = false;
2405
2745
  const next = messages.map(message => {
2406
2746
  const copy = serverCopies.get(message.id);
2407
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2408
- if (!copy || !hasLocal) return message;
2747
+ if (!copy || !hasLocalAttachments(message)) return message;
2748
+ const attachments = mergeAttachments(message.attachments, copy.attachments);
2749
+ if (attachments === message.attachments) return message;
2409
2750
  changed = true;
2410
2751
  return {
2411
2752
  ...message,
2412
- attachments: copy.attachments
2753
+ attachments
2413
2754
  };
2414
2755
  });
2415
2756
  return changed ? next : messages;
2416
2757
  }
2417
2758
 
2759
+ /**
2760
+ * Each attachment's server copy, except one without a usable URL: that would
2761
+ * only blank its local preview, so the preview stays until a later copy has it.
2762
+ * Returns `local` itself when nothing changes.
2763
+ */
2764
+ function mergeAttachments(local, server) {
2765
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2766
+ if (local.length !== server.length) {
2767
+ return server.every(attachment => attachment.url) ? server : local;
2768
+ }
2769
+ const owns = pairByName(local, server);
2770
+ const merged = server.map((attachment, index) => {
2771
+ const own = owns[index];
2772
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2773
+ rememberLocalPreview(attachment.url, own.url);
2774
+ return attachment;
2775
+ });
2776
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2777
+ }
2778
+
2779
+ /**
2780
+ * The local attachment each server one replaces. By name first: Workstation
2781
+ * may store a message's files in another order than they were sent, and a
2782
+ * photo paired with the wrong file would show it for the rest of the session.
2783
+ * Any it renamed pair up in the order left.
2784
+ */
2785
+ function pairByName(local, server) {
2786
+ const unpaired = [...local];
2787
+ const named = server.map(attachment => {
2788
+ const index = unpaired.findIndex(own => own.name === attachment.name);
2789
+ return index < 0 ? null : unpaired.splice(index, 1)[0];
2790
+ });
2791
+ return named.map(own => own ?? unpaired.shift());
2792
+ }
2793
+
2794
+ /**
2795
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2796
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2797
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2798
+ * for a message that was stored although its response was lost.
2799
+ */
2800
+ function adoptOwnServerRows(messages, server) {
2801
+ const rows = new Map();
2802
+ server.forEach(row => {
2803
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2804
+ });
2805
+ if (rows.size === 0) return messages;
2806
+ const shown = new Set(messages.map(message => message.id));
2807
+ let changed = false;
2808
+ const next = [];
2809
+ messages.forEach(message => {
2810
+ const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
2811
+ if (!row) {
2812
+ next.push(message);
2813
+ return;
2814
+ }
2815
+ changed = true;
2816
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2817
+ if (shown.has(row.id)) return;
2818
+ next.push({
2819
+ ...row,
2820
+ attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
2821
+ });
2822
+ });
2823
+ return changed ? next : messages;
2824
+ }
2825
+
2826
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2827
+ function isLocalAttachment(attachment) {
2828
+ return !/^https?:\/\//i.test(attachment.url);
2829
+ }
2830
+
2831
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2832
+ function hasLocalAttachments(message) {
2833
+ return !!message.attachments?.some(isLocalAttachment);
2834
+ }
2835
+
2836
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2837
+ function isAwaitingServerCopy(message) {
2838
+ return !isLocalMessage(message) && hasLocalAttachments(message);
2839
+ }
2840
+
2418
2841
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2419
2842
  function sortByCreatedAt(messages) {
2420
2843
  const time = message => Date.parse(message.createdAt) || 0;