@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
@@ -10,13 +10,22 @@
10
10
 
11
11
  import { AppState, type AppStateStatus, Platform } from 'react-native';
12
12
 
13
- import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient } from '../api/client';
13
+ import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient, type UploadedFile } from '../api/client';
14
14
  import { WidgetRealtime } from '../api/realtime';
15
15
  import { getDeviceId, rotateDeviceId } from '../device-id';
16
16
  import { EventEmitter, RepzoWidgetEvents } from '../events';
17
17
  import { PushManager } from '../push/push-notifications';
18
- import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from '../storage';
19
- import { setAttachmentOptions } from '../attachments/pickers';
18
+ import {
19
+ CONFIG_KEY,
20
+ LAST_MESSAGE_KEY,
21
+ LEGACY_CONFIG_KEY,
22
+ SecureStorage,
23
+ Storage,
24
+ UNREAD_KEY,
25
+ USER_KEY,
26
+ } from '../storage';
27
+ import { clearLocalPreviews, rememberLocalPreview } from '../attachments/local-previews';
28
+ import { guessContentType, isImageAttachment, setAttachmentOptions } from '../attachments/pickers';
20
29
  import { DEFAULT_BASE_URL } from '../types';
21
30
  import type {
22
31
  AgentUiAction,
@@ -37,10 +46,13 @@ import type {
37
46
  WidgetUser,
38
47
  WidgetView,
39
48
  } from '../types';
49
+ import { clearRetainedState } from '../ui/hooks/use-retained-state';
50
+ import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
51
+ import { stripHtml } from '../ui/utils/html';
40
52
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
41
53
 
42
54
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
43
- const SDK_VERSION = '@repzo/widget-react-native@1.3.0';
55
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
44
56
 
45
57
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
46
58
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -61,6 +73,12 @@ const TYPING_SETTLE_MS = 3_000;
61
73
  const TYPING_PING_INTERVAL_MS = 4_000;
62
74
  /** Guards against the SSE stream replaying messages after a reconnect. */
63
75
  const SEEN_MESSAGE_LIMIT = 100;
76
+ /** Threads whose newest message is remembered across launches. */
77
+ const LAST_MESSAGE_LIMIT = 50;
78
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
79
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
80
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
81
+ const LIST_LAG_MS = 1_000;
64
82
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
65
83
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
66
84
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -76,6 +94,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
76
94
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
77
95
  /** Live events held back while a draft's first send is creating its thread. */
78
96
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
97
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
98
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
79
99
  /** How long a post stays up after a reaction, thanking them, before it goes. */
80
100
  export const POST_THANKS_MS = 1600;
81
101
 
@@ -171,6 +191,10 @@ export class WidgetStore {
171
191
 
172
192
  /** `boot({ posts })` — Send posts over the app. */
173
193
  private postsEnabled = true;
194
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
195
+ private photoAlbumsEnabled = true;
196
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
197
+ private youPrefixEnabled = true;
174
198
 
175
199
  /** Posts whose view was reported this session (the server also dedupes per day). */
176
200
  private viewedPostIds = new Set<string>();
@@ -233,8 +257,19 @@ export class WidgetStore {
233
257
  private visitorTypingActive = false;
234
258
 
235
259
  private seenMessageIds: string[] = [];
260
+
261
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
262
+ private lastMessages: Record<string, LastMessageHint> = {};
263
+
264
+ private lastMessagesLoaded = false;
236
265
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
237
266
  private earlyStatuses = new Map<string, string>();
267
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
268
+ private failedActions = new Map<string, AgentUiAction>();
269
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
270
+ private serverCopyTimer: ReturnType<typeof setTimeout> | null = null;
271
+ private serverCopyRetries = 0;
272
+ private serverCopySyncing = false;
238
273
 
239
274
  /**
240
275
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -312,6 +347,10 @@ export class WidgetStore {
312
347
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
313
348
  getThreadGeneration = (): number => this.threadGeneration;
314
349
 
350
+ showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
351
+
352
+ showsYouPrefix = (): boolean => this.youPrefixEnabled;
353
+
315
354
  private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
316
355
  const previous = this.state;
317
356
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -385,6 +424,8 @@ export class WidgetStore {
385
424
  if (options.inAppNotifications !== undefined) {
386
425
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
387
426
  }
427
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
428
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
388
429
  if (options.posts !== undefined) {
389
430
  const wasEnabled = this.postsEnabled;
390
431
  this.postsEnabled = options.posts !== false;
@@ -546,15 +587,15 @@ export class WidgetStore {
546
587
  ]);
547
588
  if (this.isStale(epoch)) return;
548
589
 
549
- const conversations = result.conversations.map((conversation) =>
550
- this.hydrateConversation(conversation)
551
- );
552
-
553
590
  // Server counts win (they include replies pushed while the app was
554
591
  // killed); the locally persisted map only covers older Workstation
555
592
  // deployments that don't send unreadCount yet.
556
- const storedUnread = await this.loadUnread();
593
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
557
594
  if (this.isStale(epoch)) return;
595
+ const conversations = result.conversations.map((conversation) =>
596
+ this.hydrateConversation(conversation)
597
+ );
598
+
558
599
  const unreadByConversation: Record<string, number> = {};
559
600
  for (const conversation of conversations) {
560
601
  const count =
@@ -842,11 +883,15 @@ export class WidgetStore {
842
883
  this.userKeyLoaded = true;
843
884
  this.pendingAgentByConversation = {};
844
885
  this.seenMessageIds = [];
886
+ this.lastMessages = {};
887
+ this.lastMessagesLoaded = false;
845
888
  this.earlyStatuses.clear();
846
889
  this.visitorTypingActive = false;
847
890
  this.lastTypingPingAt = 0;
848
891
  this.launchCardOffered = false;
849
892
  this.resetPost();
893
+ clearLocalPreviews();
894
+ clearRetainedState();
850
895
  this.setState({ ...USER_SCOPED_STATE });
851
896
 
852
897
  const token = await this.client.getToken();
@@ -854,6 +899,7 @@ export class WidgetStore {
854
899
  this.client.clearToken(),
855
900
  SecureStorage.removeItem(USER_KEY),
856
901
  Storage.removeItem(UNREAD_KEY),
902
+ Storage.removeItem(LAST_MESSAGE_KEY),
857
903
  // A new anonymous visitor on this device gets a new tracking identity.
858
904
  rotateDeviceId(),
859
905
  ]);
@@ -890,7 +936,9 @@ export class WidgetStore {
890
936
  this.threadGeneration += 1;
891
937
  this.conversationCreation = null;
892
938
  this.draftEvents = [];
939
+ this.failedActions.clear();
893
940
  this.cancelMarkRead();
941
+ this.cancelServerCopySync();
894
942
  }
895
943
 
896
944
  private requireBootConfig() {
@@ -1244,6 +1292,7 @@ export class WidgetStore {
1244
1292
  metadata: this.hydrateMetadata(data?.metadata || null),
1245
1293
  status: 'delivered',
1246
1294
  createdAt: data?.createdAt || new Date().toISOString(),
1295
+ ...(typeof data?.clientMessageId === 'string' ? { clientMessageId: data.clientMessageId } : {}),
1247
1296
  };
1248
1297
 
1249
1298
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
@@ -1272,6 +1321,7 @@ export class WidgetStore {
1272
1321
 
1273
1322
  // Set inside the updater, read after: `setState` runs synchronously.
1274
1323
  let countedAsUnread = false;
1324
+ const rowFields = rowFieldsOf(message);
1275
1325
 
1276
1326
  this.setState((current) => {
1277
1327
  const conversations = conversationId
@@ -1279,10 +1329,7 @@ export class WidgetStore {
1279
1329
  conversation.id === conversationId
1280
1330
  ? {
1281
1331
  ...conversation,
1282
- snippet:
1283
- message.messageType === 'message'
1284
- ? message.bodyPlain || conversation.snippet
1285
- : conversation.snippet,
1332
+ ...rowFields,
1286
1333
  lastMessageAt: message.createdAt,
1287
1334
  messageCount: conversation.messageCount + 1,
1288
1335
  }
@@ -1291,12 +1338,13 @@ export class WidgetStore {
1291
1338
  : current.conversations;
1292
1339
 
1293
1340
  if (onScreen) {
1294
- const alreadyPresent = current.messages.some((existing) => existing.id === message.id);
1341
+ const shown = adoptOwnServerRows(current.messages, [message]);
1342
+ const alreadyPresent = shown.some((existing) => existing.id === message.id);
1295
1343
  return {
1296
1344
  conversations,
1297
1345
  messages: alreadyPresent
1298
- ? adoptServerAttachments(current.messages, [message])
1299
- : [...current.messages, message],
1346
+ ? adoptServerAttachments(shown, [message])
1347
+ : [...shown, message],
1300
1348
  agentTyping: clearsTyping ? false : current.agentTyping,
1301
1349
  };
1302
1350
  }
@@ -1323,12 +1371,14 @@ export class WidgetStore {
1323
1371
  messageId,
1324
1372
  senderName: message.senderName || conversation?.agentName || null,
1325
1373
  senderAvatar: conversation?.agentAvatar ?? null,
1326
- preview: (message.bodyPlain ?? '').trim(),
1374
+ preview: plainTextOf(message),
1327
1375
  hasAttachments: (message.attachments?.length ?? 0) > 0,
1328
1376
  },
1329
1377
  };
1330
1378
  });
1331
1379
 
1380
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1381
+
1332
1382
  // The visitor is reading it live — move the server cursor too, or the
1333
1383
  // next list/init would badge a reply they already saw.
1334
1384
  if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
@@ -1425,10 +1475,116 @@ export class WidgetStore {
1425
1475
  }
1426
1476
 
1427
1477
  private hydrateConversation(conversation: Conversation): Conversation {
1428
- return this.applyConversationAgent({
1478
+ return this.applyConversationAgent(
1479
+ this.withLastMessage({
1480
+ ...conversation,
1481
+ agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1482
+ })
1483
+ );
1484
+ }
1485
+
1486
+ /**
1487
+ * Workstation's list doesn't say who sent the newest message, and calls one
1488
+ * without words "2 attachments". While a row still describes the message
1489
+ * this device saw last, both are filled in from it. `snippet` keeps the
1490
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1491
+ * `lastMessageAttachment` instead.
1492
+ */
1493
+ private withLastMessage(conversation: Conversation): Conversation {
1494
+ const known = this.lastMessages[conversation.id];
1495
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1496
+ // A list that says who sent it, or what it carried, is used as is.
1497
+ return {
1429
1498
  ...conversation,
1430
- agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1499
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1500
+ lastMessageAttachment:
1501
+ conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
1502
+ };
1503
+ }
1504
+
1505
+ /**
1506
+ * A send or a streamed message is the newest by arrival, whatever the two
1507
+ * clocks say; a history page only replaces something older. A sent message
1508
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1509
+ * page that has it replaces it: whatever is newest there came after it, and
1510
+ * Workstation's own time then stands in for the phone's.
1511
+ */
1512
+ private rememberLastMessage(
1513
+ conversationId: string,
1514
+ at: string,
1515
+ fields: LastMessageFields,
1516
+ source: 'sent' | 'stream' | 'history',
1517
+ { messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
1518
+ ): void {
1519
+ const known = this.lastMessages[conversationId];
1520
+ if (source === 'history' && known) {
1521
+ const outdated =
1522
+ known.phoneClock && known.messageId
1523
+ ? !pageIds?.has(known.messageId)
1524
+ : Date.parse(known.at) > Date.parse(at);
1525
+ if (outdated) return;
1526
+ }
1527
+ const hint: LastMessageHint = {
1528
+ at,
1529
+ fromVisitor: !!fields.lastMessageFromVisitor,
1530
+ attachment: fields.lastMessageAttachment ?? null,
1531
+ ...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
1532
+ };
1533
+ this.lastMessages = Object.fromEntries(
1534
+ Object.entries({ ...this.lastMessages, [conversationId]: hint })
1535
+ .sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
1536
+ .slice(0, LAST_MESSAGE_LIMIT)
1537
+ );
1538
+ // Until the stored hints are read, saving would overwrite them with these alone.
1539
+ if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1540
+ }
1541
+
1542
+ private async loadLastMessages(epoch: number): Promise<void> {
1543
+ if (this.lastMessagesLoaded) return;
1544
+ let stored: unknown = null;
1545
+ try {
1546
+ stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
1547
+ } catch {
1548
+ stored = null;
1549
+ }
1550
+ // Left unloaded: the init that replaces this one reads them again.
1551
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1552
+ this.lastMessagesLoaded = true;
1553
+ const learned = Object.keys(this.lastMessages).length > 0;
1554
+ if (stored && typeof stored === 'object') {
1555
+ for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
1556
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1557
+ const known = this.lastMessages[conversationId];
1558
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1559
+ this.lastMessages[conversationId] = {
1560
+ at: hint.at,
1561
+ fromVisitor: hint.fromVisitor,
1562
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1563
+ ...(hint.phoneClock === true ? { phoneClock: true } : {}),
1564
+ ...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
1565
+ };
1566
+ }
1567
+ }
1568
+ // Hints learned while the stored ones were being read are saved with them.
1569
+ if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1570
+ }
1571
+
1572
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1573
+ private learnLastMessage(conversationId: string, page: Message[]): void {
1574
+ let newest: Message | null = null;
1575
+ for (const message of page) {
1576
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1577
+ }
1578
+ const fields = newest && rowFieldsOf(newest);
1579
+ if (!newest || !fields) return;
1580
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1581
+ pageIds: new Set(page.map((message) => message.id)),
1431
1582
  });
1583
+ this.setState((current) => ({
1584
+ conversations: current.conversations.map((row) =>
1585
+ row.id === conversationId ? this.withLastMessage(row) : row
1586
+ ),
1587
+ }));
1432
1588
  }
1433
1589
 
1434
1590
  /**
@@ -1439,21 +1595,30 @@ export class WidgetStore {
1439
1595
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1440
1596
  const hydrated = attachments
1441
1597
  .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
1442
- .map((attachment, index) => ({
1443
- ...attachment,
1444
- id:
1445
- typeof attachment.id === 'string' || typeof attachment.id === 'number'
1446
- ? String(attachment.id)
1447
- : `${messageId ?? 'attachment'}-${index}`,
1448
- name:
1449
- typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1450
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1451
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1452
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1453
- // then renders the name only, with nothing to open.
1454
- url:
1455
- this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1456
- }));
1598
+ .map((attachment, index) => {
1599
+ const name =
1600
+ typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1601
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1602
+ return {
1603
+ ...attachment,
1604
+ id:
1605
+ typeof attachment.id === 'string' || typeof attachment.id === 'number'
1606
+ ? String(attachment.id)
1607
+ : `${messageId ?? 'attachment'}-${index}`,
1608
+ name,
1609
+ // Without a type (or with a generic one) the name says what it is,
1610
+ // so a photo still shows as a photo rather than a file row.
1611
+ contentType:
1612
+ declared && !/^application\/octet-stream$/i.test(declared)
1613
+ ? declared
1614
+ : (guessContentType(name) ?? declared),
1615
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1616
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1617
+ // then renders the name only, with nothing to open.
1618
+ url:
1619
+ this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1620
+ };
1621
+ });
1457
1622
  return hydrated.length > 0 ? hydrated : null;
1458
1623
  }
1459
1624
 
@@ -1694,19 +1859,21 @@ export class WidgetStore {
1694
1859
  const result = await this.client.listMessages(conversationId);
1695
1860
  // Opening A then B quickly: A's page must never land under B.
1696
1861
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1862
+ const page = this.hydrateMessages(result.data);
1697
1863
  this.setState((current) => {
1698
1864
  // Live replies and optimistic sends may have arrived since this page
1699
1865
  // was requested. Keep them, preferring the live copy for shared ids.
1700
- const liveIds = new Set(current.messages.map((message) => message.id));
1701
- const page = this.hydrateMessages(result.data);
1866
+ const live = adoptOwnServerRows(current.messages, page);
1867
+ const liveIds = new Set(live.map((message) => message.id));
1702
1868
  const history = page.filter((message) => !liveIds.has(message.id));
1703
1869
  return {
1704
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1870
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1705
1871
  messagesLoading: false,
1706
1872
  hasMoreMessages: result.meta.hasMore,
1707
1873
  messageCursor: result.meta.nextCursor,
1708
1874
  };
1709
1875
  });
1876
+ this.learnLastMessage(conversationId, page);
1710
1877
  } catch {
1711
1878
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1712
1879
  this.setState({ messagesLoading: false });
@@ -1764,17 +1931,19 @@ export class WidgetStore {
1764
1931
  if (this.state.activeConversationId !== conversationId) return;
1765
1932
 
1766
1933
  const fresh = this.hydrateMessages(result.data);
1934
+ this.learnLastMessage(conversationId, fresh);
1767
1935
  this.setState((current) => {
1768
- const known = new Set(current.messages.map((message) => message.id));
1936
+ const own = adoptOwnServerRows(current.messages, fresh);
1937
+ const known = new Set(own.map((message) => message.id));
1769
1938
  const missing = fresh.filter((message) => !known.has(message.id));
1770
1939
  const upgraded = adoptServerStatuses(
1771
- adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh),
1940
+ adoptServerDeletions(adoptServerAttachments(own, fresh), fresh),
1772
1941
  fresh
1773
1942
  );
1774
1943
  if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
1775
1944
 
1776
- const local = current.messages.filter(isLocalMessage);
1777
- const hasServerMessages = local.length < current.messages.length;
1945
+ const local = own.filter(isLocalMessage);
1946
+ const hasServerMessages = local.length < own.length;
1778
1947
  // No overlap means the gap is longer than a page: restart from the
1779
1948
  // newest page (keeping unsent bubbles) rather than leave a hole.
1780
1949
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1795,6 +1964,42 @@ export class WidgetStore {
1795
1964
  }
1796
1965
  }
1797
1966
 
1967
+ /**
1968
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1969
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1970
+ * open. When the stream's echo hasn't brought that copy, re-read the
1971
+ * thread, and try again a few times while Workstation is still storing it.
1972
+ */
1973
+ private async syncServerCopies(): Promise<void> {
1974
+ this.cancelServerCopySync();
1975
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1976
+ // A send still in flight would come back as a stored row beside its own
1977
+ // pending bubble. It syncs again when it settles.
1978
+ if (this.state.messages.some((message) => isLocalMessage(message) && message.status === 'pending')) return;
1979
+ // Off screen: the thread reloads in full when it's opened again.
1980
+ const conversationId = this.state.activeConversationId;
1981
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1982
+
1983
+ const generation = this.threadGeneration;
1984
+ this.serverCopySyncing = true;
1985
+ try {
1986
+ // A no-op while a page load is in flight; the retry below covers it.
1987
+ await this.refreshOpenThread();
1988
+ } finally {
1989
+ this.serverCopySyncing = false;
1990
+ }
1991
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1992
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1993
+ if (delay === undefined) return;
1994
+ this.serverCopyRetries += 1;
1995
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1996
+ }
1997
+
1998
+ private cancelServerCopySync(): void {
1999
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
2000
+ this.serverCopyTimer = null;
2001
+ }
2002
+
1798
2003
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1799
2004
  private hydrateMessages(page: Message[]): Message[] {
1800
2005
  return page
@@ -1839,9 +2044,11 @@ export class WidgetStore {
1839
2044
 
1840
2045
  /**
1841
2046
  * Rejects when the message wasn't sent, so the composer can keep
1842
- * attachments for a retry. A text-only bubble is marked failed; one with
1843
- * attachments is removed instead — the composer's chips are the retry, and
1844
- * keeping both would leave a duplicate after a successful resend.
2047
+ * attachments for a retry. A text-only bubble is marked failed and can be
2048
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
2049
+ * composer's chips are the retry, and keeping both would leave a duplicate
2050
+ * after a successful resend. Likewise an agent UI action replaces the
2051
+ * failed bubble of the same action, since its card stays tappable.
1845
2052
  */
1846
2053
  async sendMessage(
1847
2054
  body: string,
@@ -1852,7 +2059,6 @@ export class WidgetStore {
1852
2059
  if (!trimmed && attachments.length === 0) return;
1853
2060
 
1854
2061
  const { visitorProfile } = this.state;
1855
- const generation = this.threadGeneration;
1856
2062
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1857
2063
  const optimistic: Message = {
1858
2064
  id: tempId,
@@ -1883,8 +2089,17 @@ export class WidgetStore {
1883
2089
  createdAt: new Date().toISOString(),
1884
2090
  };
1885
2091
 
2092
+ // Tapping the card again is the retry: offering the old bubble's retry
2093
+ // too would run the action twice (two items in the cart, two bookings).
2094
+ const superseded = agentUiAction
2095
+ ? [...this.failedActions]
2096
+ .filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type)
2097
+ .map(([messageId]) => messageId)
2098
+ : [];
2099
+ superseded.forEach((messageId) => this.failedActions.delete(messageId));
2100
+
1886
2101
  this.setState((current) => ({
1887
- messages: [...current.messages, optimistic],
2102
+ messages: [...current.messages.filter((message) => !superseded.includes(message.id)), optimistic],
1888
2103
  prefillText: '',
1889
2104
  }));
1890
2105
  this.track('widget_message_sent', {
@@ -1892,7 +2107,52 @@ export class WidgetStore {
1892
2107
  attachmentCount: attachments.length,
1893
2108
  });
1894
2109
 
1895
- const markFailed = () =>
2110
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
2111
+ }
2112
+
2113
+ /**
2114
+ * Puts a picked file in storage while the visitor is still writing, so
2115
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
2116
+ * when that token expires.
2117
+ */
2118
+ uploadAttachment(
2119
+ file: { uri: string; name: string; contentType: string; size: number },
2120
+ signal?: AbortSignal
2121
+ ): Promise<UploadedFile> {
2122
+ return this.client.uploadAttachment(file, signal);
2123
+ }
2124
+
2125
+ /**
2126
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
2127
+ * since it now goes out after anything sent meanwhile. Rejects like
2128
+ * `sendMessage` when it fails again; the bubble is then failed once more.
2129
+ */
2130
+ async retryMessage(messageId: string): Promise<void> {
2131
+ const failed = this.state.messages.find(
2132
+ (message) => message.id === messageId && canRetryMessage(message)
2133
+ );
2134
+ if (!failed) return;
2135
+ const agentUiAction = this.failedActions.get(messageId);
2136
+ this.failedActions.delete(messageId);
2137
+ const pending: Message = { ...failed, status: 'pending', createdAt: new Date().toISOString() };
2138
+ this.setState((current) => ({
2139
+ messages: [...current.messages.filter((message) => message.id !== messageId), pending],
2140
+ }));
2141
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
2142
+ }
2143
+
2144
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
2145
+ private async deliverMessage(
2146
+ tempId: string,
2147
+ trimmed: string,
2148
+ attachments: OutgoingAttachment[],
2149
+ agentUiAction: AgentUiAction | undefined
2150
+ ): Promise<void> {
2151
+ const { visitorProfile } = this.state;
2152
+ const generation = this.threadGeneration;
2153
+
2154
+ const markFailed = () => {
2155
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
1896
2156
  this.setState((current) => ({
1897
2157
  messages:
1898
2158
  attachments.length > 0
@@ -1901,6 +2161,7 @@ export class WidgetStore {
1901
2161
  message.id === tempId ? { ...message, status: 'failed' } : message
1902
2162
  ),
1903
2163
  }));
2164
+ };
1904
2165
 
1905
2166
  const epoch = this.epoch;
1906
2167
 
@@ -1928,26 +2189,43 @@ export class WidgetStore {
1928
2189
 
1929
2190
  let createdId: string | null = null;
1930
2191
  try {
2192
+ // Local files go up first, unless the composer already put them up; the
2193
+ // message names each by its upload token.
2194
+ const sentAttachments = await Promise.all(
2195
+ attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
2196
+ uploadToken
2197
+ ? { name, contentType, size, uploadToken }
2198
+ : fileUri
2199
+ ? {
2200
+ name,
2201
+ contentType,
2202
+ size,
2203
+ uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
2204
+ .uploadToken,
2205
+ }
2206
+ : { name, url, contentType, size }
2207
+ )
2208
+ );
2209
+ if (this.isStale(epoch)) return;
1931
2210
  const result = await this.client.sendMessage({
1932
2211
  conversationId: conversationId || undefined,
1933
2212
  forceNewConversation: !conversationId,
1934
2213
  body: trimmed,
1935
- attachments:
1936
- attachments.length > 0
1937
- ? attachments.map(({ name, url, contentType, size }) => ({
1938
- name,
1939
- url,
1940
- contentType,
1941
- size,
1942
- }))
1943
- : undefined,
2214
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1944
2215
  visitorName: visitorProfile.name,
1945
2216
  visitorEmail: visitorProfile.email,
1946
2217
  agentUiAction,
2218
+ // The bubble's id, the same on every retry: a newer Workstation stores
2219
+ // the message once even when an earlier attempt's response was lost.
2220
+ clientMessageId: tempId,
1947
2221
  });
1948
2222
  if (this.isStale(epoch)) return;
1949
2223
  if (ownCreation) createdId = result.conversationId;
1950
2224
 
2225
+ // Workstation doesn't stream the visitor's own messages back, so the
2226
+ // list row learns about this one here.
2227
+ const sentAt = new Date().toISOString();
2228
+ const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
1951
2229
  this.setState((current) => {
1952
2230
  // The stream or a catch-up fetch may have delivered this message
1953
2231
  // already — keep one copy, never two bubbles with the same id.
@@ -1960,19 +2238,27 @@ export class WidgetStore {
1960
2238
  : message
1961
2239
  );
1962
2240
 
1963
- if (!createdId) return { messages };
2241
+ if (!createdId) {
2242
+ return {
2243
+ messages,
2244
+ conversations: current.conversations.map((row) =>
2245
+ row.id === result.conversationId
2246
+ ? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
2247
+ : row
2248
+ ),
2249
+ };
2250
+ }
1964
2251
 
1965
2252
  const known = current.conversations.some((item) => item.id === result.conversationId);
1966
2253
  const conversation: Conversation = {
1967
2254
  id: result.conversationId,
1968
2255
  subject: null,
1969
2256
  status: 'open',
1970
- snippet: trimmed
1971
- ? trimmed.slice(0, 100)
1972
- : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2257
+ snippet: null,
2258
+ ...sentRow,
1973
2259
  messageCount: 1,
1974
- lastMessageAt: new Date().toISOString(),
1975
- createdAt: new Date().toISOString(),
2260
+ lastMessageAt: sentAt,
2261
+ createdAt: sentAt,
1976
2262
  agentName: null,
1977
2263
  agentAvatar: null,
1978
2264
  };
@@ -1988,8 +2274,16 @@ export class WidgetStore {
1988
2274
  : {}),
1989
2275
  };
1990
2276
  });
2277
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
2278
+
2279
+ // New files to fetch the stored copy of: a fresh round of re-reads.
2280
+ const sent = this.state.messages.find((message) => message.id === result.messageId);
2281
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1991
2282
  } catch (error) {
1992
2283
  if (this.isStale(epoch)) throw error;
2284
+ // The response was lost, but its stored row has already taken the
2285
+ // bubble's place (newer Workstation deployments): it was sent.
2286
+ if (this.state.messages.some((message) => message.clientMessageId === tempId)) return;
1993
2287
  markFailed();
1994
2288
  throw error;
1995
2289
  } finally {
@@ -2000,6 +2294,8 @@ export class WidgetStore {
2000
2294
  // Anything held back for the draft now has a home (or goes to unread).
2001
2295
  this.replayDraftEvents();
2002
2296
  }
2297
+ // Waits for every send to settle, so this runs when the last one does.
2298
+ if (!this.isStale(epoch)) void this.syncServerCopies();
2003
2299
  }
2004
2300
  }
2005
2301
 
@@ -2462,11 +2758,6 @@ function createConversationCreation(generation: number): ConversationCreation {
2462
2758
  return { generation, promise, resolve };
2463
2759
  }
2464
2760
 
2465
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2466
- function isLocalMessage(message: Message): boolean {
2467
- return message.id.startsWith('temp-');
2468
- }
2469
-
2470
2761
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2471
2762
  function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
2472
2763
  const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
@@ -2485,6 +2776,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
2485
2776
  return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
2486
2777
  }
2487
2778
 
2779
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2780
+ function plainTextOf(message: Message): string {
2781
+ return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
2782
+ }
2783
+
2784
+ /**
2785
+ * The newest message of a thread as this device saw it. `at` is its
2786
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2787
+ * message it's the phone's clock instead.
2788
+ */
2789
+ interface LastMessageHint {
2790
+ at: string;
2791
+ fromVisitor: boolean;
2792
+ attachment: 'photo' | 'file' | null;
2793
+ phoneClock?: boolean;
2794
+ /** A sent message's id: a page from Workstation that has it can retime it. */
2795
+ messageId?: string;
2796
+ }
2797
+
2798
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2799
+ type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
2800
+ snippet?: string;
2801
+ };
2802
+
2803
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2804
+ function rowFieldsOf(message: Message): LastMessageFields | null {
2805
+ if (message.messageType && message.messageType !== 'message') return null;
2806
+ return rowFieldsFor(
2807
+ plainTextOf(message),
2808
+ (message.attachments ?? []).map((attachment) => attachment.contentType),
2809
+ message.senderType === 'visitor' || message.direction === 'inbound',
2810
+ /<img\b/i.test(message.body ?? '')
2811
+ );
2812
+ }
2813
+
2814
+ /**
2815
+ * Its words, or with none, whether it carried only photos or other files too.
2816
+ * A message with neither (a card) still says who sent it, so a teammate's
2817
+ * card never leaves "You:" on the visitor's earlier message.
2818
+ */
2819
+ function rowFieldsFor(
2820
+ text: string,
2821
+ contentTypes: Array<string | null | undefined>,
2822
+ fromVisitor: boolean,
2823
+ inlinePhoto = false
2824
+ ): LastMessageFields {
2825
+ if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2826
+ const count = contentTypes.length;
2827
+ if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2828
+ return {
2829
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2830
+ ...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
2831
+ lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
2832
+ lastMessageFromVisitor: fromVisitor,
2833
+ };
2834
+ }
2835
+
2836
+ /**
2837
+ * The list stamps a conversation just after storing its message, so its time
2838
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2839
+ * A sent message is timed by the phone instead: a little drift either way
2840
+ * still matches, but only when the list rules out a team reply the visitor
2841
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2842
+ * there a sent message must match like any other.
2843
+ */
2844
+ function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
2845
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2846
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2847
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2848
+ }
2849
+
2488
2850
  /** A deletion missed while the stream was down shows up on the next re-read. */
2489
2851
  function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
2490
2852
  const deleted = new Map(
@@ -2514,14 +2876,101 @@ function adoptServerAttachments(messages: Message[], server: Message[]): Message
2514
2876
  let changed = false;
2515
2877
  const next = messages.map((message) => {
2516
2878
  const copy = serverCopies.get(message.id);
2517
- const hasLocal = message.attachments?.some((attachment) => !/^https?:\/\//i.test(attachment.url));
2518
- if (!copy || !hasLocal) return message;
2879
+ if (!copy || !hasLocalAttachments(message)) return message;
2880
+ const attachments = mergeAttachments(message.attachments!, copy.attachments!);
2881
+ if (attachments === message.attachments) return message;
2519
2882
  changed = true;
2520
- return { ...message, attachments: copy.attachments };
2883
+ return { ...message, attachments };
2521
2884
  });
2522
2885
  return changed ? next : messages;
2523
2886
  }
2524
2887
 
2888
+ /**
2889
+ * Each attachment's server copy, except one without a usable URL: that would
2890
+ * only blank its local preview, so the preview stays until a later copy has it.
2891
+ * Returns `local` itself when nothing changes.
2892
+ */
2893
+ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
2894
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2895
+ if (local.length !== server.length) {
2896
+ return server.every((attachment) => attachment.url) ? server : local;
2897
+ }
2898
+ const owns = pairByName(local, server);
2899
+ const merged = server.map((attachment, index) => {
2900
+ const own = owns[index]!;
2901
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2902
+ rememberLocalPreview(attachment.url, own.url);
2903
+ return attachment;
2904
+ });
2905
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2906
+ }
2907
+
2908
+ /**
2909
+ * The local attachment each server one replaces. By name first: Workstation
2910
+ * may store a message's files in another order than they were sent, and a
2911
+ * photo paired with the wrong file would show it for the rest of the session.
2912
+ * Any it renamed pair up in the order left.
2913
+ */
2914
+ function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
2915
+ const unpaired = [...local];
2916
+ const named = server.map((attachment) => {
2917
+ const index = unpaired.findIndex((own) => own.name === attachment.name);
2918
+ return index < 0 ? null : unpaired.splice(index, 1)[0]!;
2919
+ });
2920
+ return named.map((own) => own ?? unpaired.shift()!);
2921
+ }
2922
+
2923
+ /**
2924
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2925
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2926
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2927
+ * for a message that was stored although its response was lost.
2928
+ */
2929
+ function adoptOwnServerRows(messages: Message[], server: Message[]): Message[] {
2930
+ const rows = new Map<string, Message>();
2931
+ server.forEach((row) => {
2932
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2933
+ });
2934
+ if (rows.size === 0) return messages;
2935
+
2936
+ const shown = new Set(messages.map((message) => message.id));
2937
+ let changed = false;
2938
+ const next: Message[] = [];
2939
+ messages.forEach((message) => {
2940
+ const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
2941
+ if (!row) {
2942
+ next.push(message);
2943
+ return;
2944
+ }
2945
+ changed = true;
2946
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2947
+ if (shown.has(row.id)) return;
2948
+ next.push({
2949
+ ...row,
2950
+ attachments:
2951
+ row.attachments && message.attachments
2952
+ ? mergeAttachments(message.attachments, row.attachments)
2953
+ : (row.attachments ?? message.attachments),
2954
+ });
2955
+ });
2956
+ return changed ? next : messages;
2957
+ }
2958
+
2959
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2960
+ function isLocalAttachment(attachment: WidgetAttachment): boolean {
2961
+ return !/^https?:\/\//i.test(attachment.url);
2962
+ }
2963
+
2964
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2965
+ function hasLocalAttachments(message: Message): boolean {
2966
+ return !!message.attachments?.some(isLocalAttachment);
2967
+ }
2968
+
2969
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2970
+ function isAwaitingServerCopy(message: Message): boolean {
2971
+ return !isLocalMessage(message) && hasLocalAttachments(message);
2972
+ }
2973
+
2525
2974
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2526
2975
  function sortByCreatedAt(messages: Message[]): Message[] {
2527
2976
  const time = (message: Message) => Date.parse(message.createdAt) || 0;