@repzo/widget-react-native 1.3.1 → 1.5.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 (240) hide show
  1. package/CHANGELOG.md +173 -5
  2. package/README.md +99 -13
  3. package/lib/commonjs/api/client.js +125 -28
  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 +20 -29
  10. package/lib/commonjs/attachments/pickers.js.map +1 -1
  11. package/lib/commonjs/i18n.js +22 -3
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/locales.js +12 -3
  15. package/lib/commonjs/locales.js.map +1 -1
  16. package/lib/commonjs/state/user-update-queue.js +168 -0
  17. package/lib/commonjs/state/user-update-queue.js.map +1 -0
  18. package/lib/commonjs/state/widget-store.js +303 -33
  19. package/lib/commonjs/state/widget-store.js.map +1 -1
  20. package/lib/commonjs/storage.js +3 -1
  21. package/lib/commonjs/storage.js.map +1 -1
  22. package/lib/commonjs/types.js.map +1 -1
  23. package/lib/commonjs/ui/WidgetProvider.js +33 -13
  24. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  25. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  26. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  27. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  28. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  29. package/lib/commonjs/ui/components/ImageViewer.js +229 -24
  30. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  31. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  32. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  35. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  36. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +651 -165
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  42. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  43. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  44. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  45. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  46. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  47. package/lib/commonjs/ui/utils/html.js +13 -0
  48. package/lib/commonjs/ui/utils/html.js.map +1 -1
  49. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  50. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  51. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  52. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  53. package/lib/module/api/client.js +123 -27
  54. package/lib/module/api/client.js.map +1 -1
  55. package/lib/module/attachments/file-size.js +47 -0
  56. package/lib/module/attachments/file-size.js.map +1 -0
  57. package/lib/module/attachments/local-previews.js +33 -0
  58. package/lib/module/attachments/local-previews.js.map +1 -0
  59. package/lib/module/attachments/pickers.js +15 -25
  60. package/lib/module/attachments/pickers.js.map +1 -1
  61. package/lib/module/i18n.js +22 -3
  62. package/lib/module/i18n.js.map +1 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/locales.js +12 -3
  65. package/lib/module/locales.js.map +1 -1
  66. package/lib/module/state/user-update-queue.js +163 -0
  67. package/lib/module/state/user-update-queue.js.map +1 -0
  68. package/lib/module/state/widget-store.js +305 -35
  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 +33 -13
  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 +231 -26
  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/hooks/use-retained-state.js +34 -0
  86. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  87. package/lib/module/ui/screens/chat/ChatView.js +655 -169
  88. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  89. package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
  90. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  91. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  92. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  93. package/lib/module/ui/screens/home/HomeView.js +40 -57
  94. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  95. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  96. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  97. package/lib/module/ui/utils/html.js +12 -0
  98. package/lib/module/ui/utils/html.js.map +1 -1
  99. package/lib/module/ui/utils/image-load-queue.js +109 -0
  100. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  101. package/lib/module/ui/utils/inline-attachments.js +85 -0
  102. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  103. package/lib/typescript/commonjs/api/client.d.ts +20 -6
  104. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  106. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  108. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
  110. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/i18n.d.ts +12 -3
  112. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/index.d.ts +5 -0
  114. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/state/user-update-queue.d.ts +62 -0
  117. package/lib/typescript/commonjs/state/user-update-queue.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/state/widget-store.d.ts +44 -1
  119. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/storage.d.ts +2 -0
  121. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/types.d.ts +53 -0
  123. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  126. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  128. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  130. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  133. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  135. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  137. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
  139. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +26 -1
  141. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  142. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  144. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  146. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  148. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  150. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  152. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  154. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  155. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  156. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  157. package/lib/typescript/module/api/client.d.ts +20 -6
  158. package/lib/typescript/module/api/client.d.ts.map +1 -1
  159. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  160. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  161. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  162. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  163. package/lib/typescript/module/attachments/pickers.d.ts +15 -2
  164. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  165. package/lib/typescript/module/i18n.d.ts +12 -3
  166. package/lib/typescript/module/i18n.d.ts.map +1 -1
  167. package/lib/typescript/module/index.d.ts +5 -0
  168. package/lib/typescript/module/index.d.ts.map +1 -1
  169. package/lib/typescript/module/locales.d.ts.map +1 -1
  170. package/lib/typescript/module/state/user-update-queue.d.ts +62 -0
  171. package/lib/typescript/module/state/user-update-queue.d.ts.map +1 -0
  172. package/lib/typescript/module/state/widget-store.d.ts +44 -1
  173. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  174. package/lib/typescript/module/storage.d.ts +2 -0
  175. package/lib/typescript/module/storage.d.ts.map +1 -1
  176. package/lib/typescript/module/types.d.ts +53 -0
  177. package/lib/typescript/module/types.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  179. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  180. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  181. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  182. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  184. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  186. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  187. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  188. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  189. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  190. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  191. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  192. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
  193. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  194. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +26 -1
  195. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  196. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  197. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  198. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  199. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  200. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  201. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  202. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  203. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  204. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  205. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  206. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  207. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  208. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  209. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  210. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  211. package/package.json +1 -1
  212. package/src/api/client.ts +149 -38
  213. package/src/attachments/file-size.ts +42 -0
  214. package/src/attachments/local-previews.ts +32 -0
  215. package/src/attachments/pickers.ts +23 -25
  216. package/src/i18n.ts +22 -3
  217. package/src/index.tsx +5 -0
  218. package/src/locales.ts +12 -3
  219. package/src/state/user-update-queue.ts +178 -0
  220. package/src/state/widget-store.ts +346 -51
  221. package/src/storage.ts +2 -0
  222. package/src/types.ts +53 -0
  223. package/src/ui/WidgetProvider.tsx +35 -8
  224. package/src/ui/components/AttachmentChips.tsx +100 -29
  225. package/src/ui/components/ConversationRow.tsx +90 -9
  226. package/src/ui/components/ImageViewer.tsx +242 -24
  227. package/src/ui/components/RatingCard.tsx +5 -2
  228. package/src/ui/components/ZoomableImage.tsx +58 -5
  229. package/src/ui/hooks/use-retained-state.ts +35 -0
  230. package/src/ui/screens/chat/ChatView.styles.ts +139 -1
  231. package/src/ui/screens/chat/ChatView.tsx +735 -198
  232. package/src/ui/screens/chat/ChatView.types.ts +26 -1
  233. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  234. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  235. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  236. package/src/ui/screens/home/HomeView.tsx +54 -70
  237. package/src/ui/screens/home/HomeView.types.ts +5 -6
  238. package/src/ui/utils/html.ts +26 -0
  239. package/src/ui/utils/image-load-queue.ts +108 -0
  240. 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,11 +46,14 @@ import type {
37
46
  WidgetUser,
38
47
  WidgetView,
39
48
  } from '../types';
49
+ import { clearRetainedState } from '../ui/hooks/use-retained-state';
40
50
  import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
51
+ import { stripHtml } from '../ui/utils/html';
41
52
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
53
+ import { UserUpdateQueue } from './user-update-queue';
42
54
 
43
55
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
44
- const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
56
+ const SDK_VERSION = '@repzo/widget-react-native@1.5.0';
45
57
 
46
58
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
47
59
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -62,6 +74,12 @@ const TYPING_SETTLE_MS = 3_000;
62
74
  const TYPING_PING_INTERVAL_MS = 4_000;
63
75
  /** Guards against the SSE stream replaying messages after a reconnect. */
64
76
  const SEEN_MESSAGE_LIMIT = 100;
77
+ /** Threads whose newest message is remembered across launches. */
78
+ const LAST_MESSAGE_LIMIT = 50;
79
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
80
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
81
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
82
+ const LIST_LAG_MS = 1_000;
65
83
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
66
84
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
67
85
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -144,6 +162,11 @@ const USER_SCOPED_STATE: Partial<WidgetState> = {
144
162
  export class WidgetStore {
145
163
  readonly client = new WidgetApiClient();
146
164
 
165
+ /** Paces `updateUser()` — see `boot({ throttleUpdateUser })`. */
166
+ private readonly userUpdates = new UserUpdateQueue((customAttributes) =>
167
+ this.client.identify({ customAttributes })
168
+ );
169
+
147
170
  readonly events = new EventEmitter();
148
171
 
149
172
  readonly push = new PushManager(
@@ -174,6 +197,12 @@ export class WidgetStore {
174
197
 
175
198
  /** `boot({ posts })` — Send posts over the app. */
176
199
  private postsEnabled = true;
200
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
201
+ private photoAlbumsEnabled = true;
202
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
203
+ private youPrefixEnabled = true;
204
+ /** `boot({ throttleUpdateUser })` — skip unchanged `updateUser()` values and pace the rest. */
205
+ private throttleUpdateUserEnabled = true;
177
206
 
178
207
  /** Posts whose view was reported this session (the server also dedupes per day). */
179
208
  private viewedPostIds = new Set<string>();
@@ -236,6 +265,11 @@ export class WidgetStore {
236
265
  private visitorTypingActive = false;
237
266
 
238
267
  private seenMessageIds: string[] = [];
268
+
269
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
270
+ private lastMessages: Record<string, LastMessageHint> = {};
271
+
272
+ private lastMessagesLoaded = false;
239
273
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
240
274
  private earlyStatuses = new Map<string, string>();
241
275
  /** The agent UI action behind a failed bubble, so Retry runs it again too. */
@@ -321,6 +355,10 @@ export class WidgetStore {
321
355
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
322
356
  getThreadGeneration = (): number => this.threadGeneration;
323
357
 
358
+ showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
359
+
360
+ showsYouPrefix = (): boolean => this.youPrefixEnabled;
361
+
324
362
  private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
325
363
  const previous = this.state;
326
364
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -394,6 +432,11 @@ export class WidgetStore {
394
432
  if (options.inAppNotifications !== undefined) {
395
433
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
396
434
  }
435
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
436
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
437
+ if (options.throttleUpdateUser !== undefined) {
438
+ this.throttleUpdateUserEnabled = options.throttleUpdateUser !== false;
439
+ }
397
440
  if (options.posts !== undefined) {
398
441
  const wasEnabled = this.postsEnabled;
399
442
  this.postsEnabled = options.posts !== false;
@@ -555,15 +598,15 @@ export class WidgetStore {
555
598
  ]);
556
599
  if (this.isStale(epoch)) return;
557
600
 
558
- const conversations = result.conversations.map((conversation) =>
559
- this.hydrateConversation(conversation)
560
- );
561
-
562
601
  // Server counts win (they include replies pushed while the app was
563
602
  // killed); the locally persisted map only covers older Workstation
564
603
  // deployments that don't send unreadCount yet.
565
- const storedUnread = await this.loadUnread();
604
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
566
605
  if (this.isStale(epoch)) return;
606
+ const conversations = result.conversations.map((conversation) =>
607
+ this.hydrateConversation(conversation)
608
+ );
609
+
567
610
  const unreadByConversation: Record<string, number> = {};
568
611
  for (const conversation of conversations) {
569
612
  const count =
@@ -577,6 +620,10 @@ export class WidgetStore {
577
620
  if (onScreenId && this.isThreadOnScreen(onScreenId)) delete unreadByConversation[onScreenId];
578
621
  this.persistUnread(unreadByConversation);
579
622
 
623
+ // A new identity (anonymous → signed in) may hold other attributes, so
624
+ // nothing is skipped as "already sent" for it.
625
+ if (result.identityId !== this.state.identityId) this.userUpdates.forgetSent();
626
+
580
627
  this.setState((current) => ({
581
628
  booted: true,
582
629
  loading: false,
@@ -800,7 +847,11 @@ export class WidgetStore {
800
847
  const customAttributes = sanitizeAttributes(attributes);
801
848
  if (Object.keys(customAttributes).length === 0) return;
802
849
 
803
- await this.client.identify({ customAttributes });
850
+ if (!this.throttleUpdateUserEnabled) {
851
+ await this.client.identify({ customAttributes });
852
+ return;
853
+ }
854
+ await this.userUpdates.update(customAttributes);
804
855
  }
805
856
 
806
857
  /**
@@ -812,6 +863,7 @@ export class WidgetStore {
812
863
  this.loggedOut = true;
813
864
  this.currentUser = null;
814
865
  this.pendingConversationId = null;
866
+ this.userUpdates.reset();
815
867
  this.realtime.close();
816
868
  this.clearTypingTimer();
817
869
  this.resetThreadTracking();
@@ -843,6 +895,8 @@ export class WidgetStore {
843
895
 
844
896
  private async wipeSessionUnguarded(): Promise<void> {
845
897
  this.realtime.close();
898
+ // Attributes waiting for the old visitor must never reach the next one.
899
+ this.userUpdates.reset();
846
900
  this.clearTypingTimer();
847
901
  this.resetThreadTracking();
848
902
  this.appStateSubscription?.remove();
@@ -851,11 +905,15 @@ export class WidgetStore {
851
905
  this.userKeyLoaded = true;
852
906
  this.pendingAgentByConversation = {};
853
907
  this.seenMessageIds = [];
908
+ this.lastMessages = {};
909
+ this.lastMessagesLoaded = false;
854
910
  this.earlyStatuses.clear();
855
911
  this.visitorTypingActive = false;
856
912
  this.lastTypingPingAt = 0;
857
913
  this.launchCardOffered = false;
858
914
  this.resetPost();
915
+ clearLocalPreviews();
916
+ clearRetainedState();
859
917
  this.setState({ ...USER_SCOPED_STATE });
860
918
 
861
919
  const token = await this.client.getToken();
@@ -863,6 +921,7 @@ export class WidgetStore {
863
921
  this.client.clearToken(),
864
922
  SecureStorage.removeItem(USER_KEY),
865
923
  Storage.removeItem(UNREAD_KEY),
924
+ Storage.removeItem(LAST_MESSAGE_KEY),
866
925
  // A new anonymous visitor on this device gets a new tracking identity.
867
926
  rotateDeviceId(),
868
927
  ]);
@@ -1284,6 +1343,7 @@ export class WidgetStore {
1284
1343
 
1285
1344
  // Set inside the updater, read after: `setState` runs synchronously.
1286
1345
  let countedAsUnread = false;
1346
+ const rowFields = rowFieldsOf(message);
1287
1347
 
1288
1348
  this.setState((current) => {
1289
1349
  const conversations = conversationId
@@ -1291,10 +1351,7 @@ export class WidgetStore {
1291
1351
  conversation.id === conversationId
1292
1352
  ? {
1293
1353
  ...conversation,
1294
- snippet:
1295
- message.messageType === 'message'
1296
- ? message.bodyPlain || conversation.snippet
1297
- : conversation.snippet,
1354
+ ...rowFields,
1298
1355
  lastMessageAt: message.createdAt,
1299
1356
  messageCount: conversation.messageCount + 1,
1300
1357
  }
@@ -1336,12 +1393,14 @@ export class WidgetStore {
1336
1393
  messageId,
1337
1394
  senderName: message.senderName || conversation?.agentName || null,
1338
1395
  senderAvatar: conversation?.agentAvatar ?? null,
1339
- preview: (message.bodyPlain ?? '').trim(),
1396
+ preview: plainTextOf(message),
1340
1397
  hasAttachments: (message.attachments?.length ?? 0) > 0,
1341
1398
  },
1342
1399
  };
1343
1400
  });
1344
1401
 
1402
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1403
+
1345
1404
  // The visitor is reading it live — move the server cursor too, or the
1346
1405
  // next list/init would badge a reply they already saw.
1347
1406
  if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
@@ -1438,10 +1497,116 @@ export class WidgetStore {
1438
1497
  }
1439
1498
 
1440
1499
  private hydrateConversation(conversation: Conversation): Conversation {
1441
- return this.applyConversationAgent({
1500
+ return this.applyConversationAgent(
1501
+ this.withLastMessage({
1502
+ ...conversation,
1503
+ agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1504
+ })
1505
+ );
1506
+ }
1507
+
1508
+ /**
1509
+ * Workstation's list doesn't say who sent the newest message, and calls one
1510
+ * without words "2 attachments". While a row still describes the message
1511
+ * this device saw last, both are filled in from it. `snippet` keeps the
1512
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1513
+ * `lastMessageAttachment` instead.
1514
+ */
1515
+ private withLastMessage(conversation: Conversation): Conversation {
1516
+ const known = this.lastMessages[conversation.id];
1517
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1518
+ // A list that says who sent it, or what it carried, is used as is.
1519
+ return {
1442
1520
  ...conversation,
1443
- agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1521
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1522
+ lastMessageAttachment:
1523
+ conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
1524
+ };
1525
+ }
1526
+
1527
+ /**
1528
+ * A send or a streamed message is the newest by arrival, whatever the two
1529
+ * clocks say; a history page only replaces something older. A sent message
1530
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1531
+ * page that has it replaces it: whatever is newest there came after it, and
1532
+ * Workstation's own time then stands in for the phone's.
1533
+ */
1534
+ private rememberLastMessage(
1535
+ conversationId: string,
1536
+ at: string,
1537
+ fields: LastMessageFields,
1538
+ source: 'sent' | 'stream' | 'history',
1539
+ { messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
1540
+ ): void {
1541
+ const known = this.lastMessages[conversationId];
1542
+ if (source === 'history' && known) {
1543
+ const outdated =
1544
+ known.phoneClock && known.messageId
1545
+ ? !pageIds?.has(known.messageId)
1546
+ : Date.parse(known.at) > Date.parse(at);
1547
+ if (outdated) return;
1548
+ }
1549
+ const hint: LastMessageHint = {
1550
+ at,
1551
+ fromVisitor: !!fields.lastMessageFromVisitor,
1552
+ attachment: fields.lastMessageAttachment ?? null,
1553
+ ...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
1554
+ };
1555
+ this.lastMessages = Object.fromEntries(
1556
+ Object.entries({ ...this.lastMessages, [conversationId]: hint })
1557
+ .sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
1558
+ .slice(0, LAST_MESSAGE_LIMIT)
1559
+ );
1560
+ // Until the stored hints are read, saving would overwrite them with these alone.
1561
+ if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1562
+ }
1563
+
1564
+ private async loadLastMessages(epoch: number): Promise<void> {
1565
+ if (this.lastMessagesLoaded) return;
1566
+ let stored: unknown = null;
1567
+ try {
1568
+ stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
1569
+ } catch {
1570
+ stored = null;
1571
+ }
1572
+ // Left unloaded: the init that replaces this one reads them again.
1573
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1574
+ this.lastMessagesLoaded = true;
1575
+ const learned = Object.keys(this.lastMessages).length > 0;
1576
+ if (stored && typeof stored === 'object') {
1577
+ for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
1578
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1579
+ const known = this.lastMessages[conversationId];
1580
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1581
+ this.lastMessages[conversationId] = {
1582
+ at: hint.at,
1583
+ fromVisitor: hint.fromVisitor,
1584
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1585
+ ...(hint.phoneClock === true ? { phoneClock: true } : {}),
1586
+ ...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
1587
+ };
1588
+ }
1589
+ }
1590
+ // Hints learned while the stored ones were being read are saved with them.
1591
+ if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1592
+ }
1593
+
1594
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1595
+ private learnLastMessage(conversationId: string, page: Message[]): void {
1596
+ let newest: Message | null = null;
1597
+ for (const message of page) {
1598
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1599
+ }
1600
+ const fields = newest && rowFieldsOf(newest);
1601
+ if (!newest || !fields) return;
1602
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1603
+ pageIds: new Set(page.map((message) => message.id)),
1444
1604
  });
1605
+ this.setState((current) => ({
1606
+ conversations: current.conversations.map((row) =>
1607
+ row.id === conversationId ? this.withLastMessage(row) : row
1608
+ ),
1609
+ }));
1445
1610
  }
1446
1611
 
1447
1612
  /**
@@ -1452,21 +1617,30 @@ export class WidgetStore {
1452
1617
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1453
1618
  const hydrated = attachments
1454
1619
  .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
1455
- .map((attachment, index) => ({
1456
- ...attachment,
1457
- id:
1458
- typeof attachment.id === 'string' || typeof attachment.id === 'number'
1459
- ? String(attachment.id)
1460
- : `${messageId ?? 'attachment'}-${index}`,
1461
- name:
1462
- typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1463
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1464
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1465
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1466
- // then renders the name only, with nothing to open.
1467
- url:
1468
- this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1469
- }));
1620
+ .map((attachment, index) => {
1621
+ const name =
1622
+ typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1623
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1624
+ return {
1625
+ ...attachment,
1626
+ id:
1627
+ typeof attachment.id === 'string' || typeof attachment.id === 'number'
1628
+ ? String(attachment.id)
1629
+ : `${messageId ?? 'attachment'}-${index}`,
1630
+ name,
1631
+ // Without a type (or with a generic one) the name says what it is,
1632
+ // so a photo still shows as a photo rather than a file row.
1633
+ contentType:
1634
+ declared && !/^application\/octet-stream$/i.test(declared)
1635
+ ? declared
1636
+ : (guessContentType(name) ?? declared),
1637
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1638
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1639
+ // then renders the name only, with nothing to open.
1640
+ url:
1641
+ this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1642
+ };
1643
+ });
1470
1644
  return hydrated.length > 0 ? hydrated : null;
1471
1645
  }
1472
1646
 
@@ -1707,10 +1881,10 @@ export class WidgetStore {
1707
1881
  const result = await this.client.listMessages(conversationId);
1708
1882
  // Opening A then B quickly: A's page must never land under B.
1709
1883
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1884
+ const page = this.hydrateMessages(result.data);
1710
1885
  this.setState((current) => {
1711
1886
  // Live replies and optimistic sends may have arrived since this page
1712
1887
  // was requested. Keep them, preferring the live copy for shared ids.
1713
- const page = this.hydrateMessages(result.data);
1714
1888
  const live = adoptOwnServerRows(current.messages, page);
1715
1889
  const liveIds = new Set(live.map((message) => message.id));
1716
1890
  const history = page.filter((message) => !liveIds.has(message.id));
@@ -1721,6 +1895,7 @@ export class WidgetStore {
1721
1895
  messageCursor: result.meta.nextCursor,
1722
1896
  };
1723
1897
  });
1898
+ this.learnLastMessage(conversationId, page);
1724
1899
  } catch {
1725
1900
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1726
1901
  this.setState({ messagesLoading: false });
@@ -1778,6 +1953,7 @@ export class WidgetStore {
1778
1953
  if (this.state.activeConversationId !== conversationId) return;
1779
1954
 
1780
1955
  const fresh = this.hydrateMessages(result.data);
1956
+ this.learnLastMessage(conversationId, fresh);
1781
1957
  this.setState((current) => {
1782
1958
  const own = adoptOwnServerRows(current.messages, fresh);
1783
1959
  const known = new Set(own.map((message) => message.id));
@@ -1956,6 +2132,18 @@ export class WidgetStore {
1956
2132
  return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1957
2133
  }
1958
2134
 
2135
+ /**
2136
+ * Puts a picked file in storage while the visitor is still writing, so
2137
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
2138
+ * when that token expires.
2139
+ */
2140
+ uploadAttachment(
2141
+ file: { uri: string; name: string; contentType: string; size: number },
2142
+ signal?: AbortSignal
2143
+ ): Promise<UploadedFile> {
2144
+ return this.client.uploadAttachment(file, signal);
2145
+ }
2146
+
1959
2147
  /**
1960
2148
  * Re-sends a text bubble that failed. It moves to the end of the thread,
1961
2149
  * since it now goes out after anything sent meanwhile. Rejects like
@@ -2023,17 +2211,21 @@ export class WidgetStore {
2023
2211
 
2024
2212
  let createdId: string | null = null;
2025
2213
  try {
2026
- // Local files go up first; the message names each by its upload token.
2214
+ // Local files go up first, unless the composer already put them up; the
2215
+ // message names each by its upload token.
2027
2216
  const sentAttachments = await Promise.all(
2028
- attachments.map(async ({ name, url, fileUri, contentType, size }) =>
2029
- fileUri
2030
- ? {
2031
- name,
2032
- contentType,
2033
- size,
2034
- uploadToken: await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }),
2035
- }
2036
- : { name, url, contentType, size }
2217
+ attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
2218
+ uploadToken
2219
+ ? { name, contentType, size, uploadToken }
2220
+ : fileUri
2221
+ ? {
2222
+ name,
2223
+ contentType,
2224
+ size,
2225
+ uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
2226
+ .uploadToken,
2227
+ }
2228
+ : { name, url, contentType, size }
2037
2229
  )
2038
2230
  );
2039
2231
  if (this.isStale(epoch)) return;
@@ -2052,6 +2244,10 @@ export class WidgetStore {
2052
2244
  if (this.isStale(epoch)) return;
2053
2245
  if (ownCreation) createdId = result.conversationId;
2054
2246
 
2247
+ // Workstation doesn't stream the visitor's own messages back, so the
2248
+ // list row learns about this one here.
2249
+ const sentAt = new Date().toISOString();
2250
+ const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
2055
2251
  this.setState((current) => {
2056
2252
  // The stream or a catch-up fetch may have delivered this message
2057
2253
  // already — keep one copy, never two bubbles with the same id.
@@ -2064,19 +2260,27 @@ export class WidgetStore {
2064
2260
  : message
2065
2261
  );
2066
2262
 
2067
- if (!createdId) return { messages };
2263
+ if (!createdId) {
2264
+ return {
2265
+ messages,
2266
+ conversations: current.conversations.map((row) =>
2267
+ row.id === result.conversationId
2268
+ ? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
2269
+ : row
2270
+ ),
2271
+ };
2272
+ }
2068
2273
 
2069
2274
  const known = current.conversations.some((item) => item.id === result.conversationId);
2070
2275
  const conversation: Conversation = {
2071
2276
  id: result.conversationId,
2072
2277
  subject: null,
2073
2278
  status: 'open',
2074
- snippet: trimmed
2075
- ? trimmed.slice(0, 100)
2076
- : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2279
+ snippet: null,
2280
+ ...sentRow,
2077
2281
  messageCount: 1,
2078
- lastMessageAt: new Date().toISOString(),
2079
- createdAt: new Date().toISOString(),
2282
+ lastMessageAt: sentAt,
2283
+ createdAt: sentAt,
2080
2284
  agentName: null,
2081
2285
  agentAvatar: null,
2082
2286
  };
@@ -2092,6 +2296,7 @@ export class WidgetStore {
2092
2296
  : {}),
2093
2297
  };
2094
2298
  });
2299
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
2095
2300
 
2096
2301
  // New files to fetch the stored copy of: a fresh round of re-reads.
2097
2302
  const sent = this.state.messages.find((message) => message.id === result.messageId);
@@ -2547,6 +2752,7 @@ export class WidgetStore {
2547
2752
  // ─── Teardown (tests / hot reload) ──────────────────────────────────────
2548
2753
 
2549
2754
  destroy(): void {
2755
+ this.userUpdates.destroy();
2550
2756
  this.clearPostRecheck();
2551
2757
  this.clearPostThanks();
2552
2758
  this.realtime.close();
@@ -2593,6 +2799,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
2593
2799
  return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
2594
2800
  }
2595
2801
 
2802
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2803
+ function plainTextOf(message: Message): string {
2804
+ return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
2805
+ }
2806
+
2807
+ /**
2808
+ * The newest message of a thread as this device saw it. `at` is its
2809
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2810
+ * message it's the phone's clock instead.
2811
+ */
2812
+ interface LastMessageHint {
2813
+ at: string;
2814
+ fromVisitor: boolean;
2815
+ attachment: 'photo' | 'file' | null;
2816
+ phoneClock?: boolean;
2817
+ /** A sent message's id: a page from Workstation that has it can retime it. */
2818
+ messageId?: string;
2819
+ }
2820
+
2821
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2822
+ type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
2823
+ snippet?: string;
2824
+ };
2825
+
2826
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2827
+ function rowFieldsOf(message: Message): LastMessageFields | null {
2828
+ if (message.messageType && message.messageType !== 'message') return null;
2829
+ return rowFieldsFor(
2830
+ plainTextOf(message),
2831
+ (message.attachments ?? []).map((attachment) => attachment.contentType),
2832
+ message.senderType === 'visitor' || message.direction === 'inbound',
2833
+ /<img\b/i.test(message.body ?? '')
2834
+ );
2835
+ }
2836
+
2837
+ /**
2838
+ * Its words, or with none, whether it carried only photos or other files too.
2839
+ * A message with neither (a card) still says who sent it, so a teammate's
2840
+ * card never leaves "You:" on the visitor's earlier message.
2841
+ */
2842
+ function rowFieldsFor(
2843
+ text: string,
2844
+ contentTypes: Array<string | null | undefined>,
2845
+ fromVisitor: boolean,
2846
+ inlinePhoto = false
2847
+ ): LastMessageFields {
2848
+ if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2849
+ const count = contentTypes.length;
2850
+ if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2851
+ return {
2852
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2853
+ ...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
2854
+ lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
2855
+ lastMessageFromVisitor: fromVisitor,
2856
+ };
2857
+ }
2858
+
2859
+ /**
2860
+ * The list stamps a conversation just after storing its message, so its time
2861
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2862
+ * A sent message is timed by the phone instead: a little drift either way
2863
+ * still matches, but only when the list rules out a team reply the visitor
2864
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2865
+ * there a sent message must match like any other.
2866
+ */
2867
+ function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
2868
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2869
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2870
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2871
+ }
2872
+
2596
2873
  /** A deletion missed while the stream was down shows up on the next re-read. */
2597
2874
  function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
2598
2875
  const deleted = new Map(
@@ -2641,13 +2918,31 @@ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[])
2641
2918
  if (local.length !== server.length) {
2642
2919
  return server.every((attachment) => attachment.url) ? server : local;
2643
2920
  }
2921
+ const owns = pairByName(local, server);
2644
2922
  const merged = server.map((attachment, index) => {
2645
- const own = local[index]!;
2646
- return isLocalAttachment(own) && attachment.url ? attachment : own;
2923
+ const own = owns[index]!;
2924
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2925
+ rememberLocalPreview(attachment.url, own.url);
2926
+ return attachment;
2647
2927
  });
2648
2928
  return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2649
2929
  }
2650
2930
 
2931
+ /**
2932
+ * The local attachment each server one replaces. By name first: Workstation
2933
+ * may store a message's files in another order than they were sent, and a
2934
+ * photo paired with the wrong file would show it for the rest of the session.
2935
+ * Any it renamed pair up in the order left.
2936
+ */
2937
+ function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
2938
+ const unpaired = [...local];
2939
+ const named = server.map((attachment) => {
2940
+ const index = unpaired.findIndex((own) => own.name === attachment.name);
2941
+ return index < 0 ? null : unpaired.splice(index, 1)[0]!;
2942
+ });
2943
+ return named.map((own) => own ?? unpaired.shift()!);
2944
+ }
2945
+
2651
2946
  /**
2652
2947
  * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2653
2948
  * rows. Such a row replaces the pending or failed bubble it came from instead
package/src/storage.ts CHANGED
@@ -20,6 +20,8 @@ export const TOKEN_KEY = 'repzo_widget_token';
20
20
  export const USER_KEY = 'repzo_widget_user_key';
21
21
  export const DEVICE_ID_KEY = 'repzo_widget_device_id';
22
22
  export const UNREAD_KEY = 'repzo_widget_unread';
23
+ /** Who sent each conversation's newest message; Workstation's list doesn't say. */
24
+ export const LAST_MESSAGE_KEY = 'repzo_widget_last_message';
23
25
  /** Last config received — repaints the real theme instantly on relaunch. */
24
26
  /**
25
27
  * v2 wraps the config with the channel/baseUrl/platform it came from. v1 held a