@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
@@ -11,10 +11,14 @@ var _deviceId = require("../device-id.js");
11
11
  var _events = require("../events.js");
12
12
  var _pushNotifications = require("../push/push-notifications.js");
13
13
  var _storage = require("../storage.js");
14
+ var _localPreviews = require("../attachments/local-previews.js");
14
15
  var _pickers = require("../attachments/pickers.js");
15
16
  var _types = require("../types.js");
17
+ var _useRetainedState = require("../ui/hooks/use-retained-state.js");
16
18
  var _conversations = require("../ui/utils/conversations.js");
19
+ var _html = require("../ui/utils/html.js");
17
20
  var _safeUrl = require("../ui/utils/safe-url.js");
21
+ var _userUpdateQueue = require("./user-update-queue.js");
18
22
  /**
19
23
  * The widget's single source of truth.
20
24
  *
@@ -26,7 +30,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
26
30
  */
27
31
 
28
32
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
29
- const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
33
+ const SDK_VERSION = '@repzo/widget-react-native@1.5.0';
30
34
 
31
35
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
32
36
  function sessionPlatform() {
@@ -41,6 +45,12 @@ const TYPING_SETTLE_MS = 3_000;
41
45
  const TYPING_PING_INTERVAL_MS = 4_000;
42
46
  /** Guards against the SSE stream replaying messages after a reconnect. */
43
47
  const SEEN_MESSAGE_LIMIT = 100;
48
+ /** Threads whose newest message is remembered across launches. */
49
+ const LAST_MESSAGE_LIMIT = 50;
50
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
51
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
52
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
53
+ const LIST_LAG_MS = 1_000;
44
54
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
45
55
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
46
56
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -118,6 +128,11 @@ const USER_SCOPED_STATE = {
118
128
  };
119
129
  class WidgetStore {
120
130
  client = new _client.WidgetApiClient();
131
+
132
+ /** Paces `updateUser()` — see `boot({ throttleUpdateUser })`. */
133
+ userUpdates = new _userUpdateQueue.UserUpdateQueue(customAttributes => this.client.identify({
134
+ customAttributes
135
+ }));
121
136
  events = new _events.EventEmitter();
122
137
  push = new _pushNotifications.PushManager(this.client, conversationId => {
123
138
  void this.openConversation(conversationId);
@@ -134,6 +149,12 @@ class WidgetStore {
134
149
 
135
150
  /** `boot({ posts })` — Send posts over the app. */
136
151
  postsEnabled = true;
152
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
153
+ photoAlbumsEnabled = true;
154
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
155
+ youPrefixEnabled = true;
156
+ /** `boot({ throttleUpdateUser })` — skip unchanged `updateUser()` values and pace the rest. */
157
+ throttleUpdateUserEnabled = true;
137
158
 
138
159
  /** Posts whose view was reported this session (the server also dedupes per day). */
139
160
  viewedPostIds = new Set();
@@ -188,6 +209,10 @@ class WidgetStore {
188
209
  lastTypingPingAt = 0;
189
210
  visitorTypingActive = false;
190
211
  seenMessageIds = [];
212
+
213
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
214
+ lastMessages = {};
215
+ lastMessagesLoaded = false;
191
216
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
192
217
  earlyStatuses = new Map();
193
218
  /** The agent UI action behind a failed bubble, so Retry runs it again too. */
@@ -267,6 +292,8 @@ class WidgetStore {
267
292
 
268
293
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
269
294
  getThreadGeneration = () => this.threadGeneration;
295
+ showsPhotoAlbums = () => this.photoAlbumsEnabled;
296
+ showsYouPrefix = () => this.youPrefixEnabled;
270
297
  setState(patch) {
271
298
  const previous = this.state;
272
299
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -338,6 +365,11 @@ class WidgetStore {
338
365
  if (options.inAppNotifications !== undefined) {
339
366
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
340
367
  }
368
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
369
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
370
+ if (options.throttleUpdateUser !== undefined) {
371
+ this.throttleUpdateUserEnabled = options.throttleUpdateUser !== false;
372
+ }
341
373
  if (options.posts !== undefined) {
342
374
  const wasEnabled = this.postsEnabled;
343
375
  this.postsEnabled = options.posts !== false;
@@ -492,13 +524,13 @@ class WidgetStore {
492
524
  // queued op must never see one without the other.
493
525
  await Promise.all([this.client.setToken(result.token), userKey ? _storage.SecureStorage.setItem(_storage.USER_KEY, userKey) : _storage.SecureStorage.removeItem(_storage.USER_KEY)]);
494
526
  if (this.isStale(epoch)) return;
495
- const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
496
527
 
497
528
  // Server counts win (they include replies pushed while the app was
498
529
  // killed); the locally persisted map only covers older Workstation
499
530
  // deployments that don't send unreadCount yet.
500
- const storedUnread = await this.loadUnread();
531
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
501
532
  if (this.isStale(epoch)) return;
533
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
502
534
  const unreadByConversation = {};
503
535
  for (const conversation of conversations) {
504
536
  const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
@@ -508,6 +540,10 @@ class WidgetStore {
508
540
  const onScreenId = this.state.activeConversationId;
509
541
  if (onScreenId && this.isThreadOnScreen(onScreenId)) delete unreadByConversation[onScreenId];
510
542
  this.persistUnread(unreadByConversation);
543
+
544
+ // A new identity (anonymous → signed in) may hold other attributes, so
545
+ // nothing is skipped as "already sent" for it.
546
+ if (result.identityId !== this.state.identityId) this.userUpdates.forgetSent();
511
547
  this.setState(current => ({
512
548
  booted: true,
513
549
  loading: false,
@@ -736,9 +772,13 @@ class WidgetStore {
736
772
  if (!this.state.booted) await this.bootPromise;
737
773
  const customAttributes = sanitizeAttributes(attributes);
738
774
  if (Object.keys(customAttributes).length === 0) return;
739
- await this.client.identify({
740
- customAttributes
741
- });
775
+ if (!this.throttleUpdateUserEnabled) {
776
+ await this.client.identify({
777
+ customAttributes
778
+ });
779
+ return;
780
+ }
781
+ await this.userUpdates.update(customAttributes);
742
782
  }
743
783
 
744
784
  /**
@@ -750,6 +790,7 @@ class WidgetStore {
750
790
  this.loggedOut = true;
751
791
  this.currentUser = null;
752
792
  this.pendingConversationId = null;
793
+ this.userUpdates.reset();
753
794
  this.realtime.close();
754
795
  this.clearTypingTimer();
755
796
  this.resetThreadTracking();
@@ -781,6 +822,8 @@ class WidgetStore {
781
822
  }
782
823
  async wipeSessionUnguarded() {
783
824
  this.realtime.close();
825
+ // Attributes waiting for the old visitor must never reach the next one.
826
+ this.userUpdates.reset();
784
827
  this.clearTypingTimer();
785
828
  this.resetThreadTracking();
786
829
  this.appStateSubscription?.remove();
@@ -789,16 +832,20 @@ class WidgetStore {
789
832
  this.userKeyLoaded = true;
790
833
  this.pendingAgentByConversation = {};
791
834
  this.seenMessageIds = [];
835
+ this.lastMessages = {};
836
+ this.lastMessagesLoaded = false;
792
837
  this.earlyStatuses.clear();
793
838
  this.visitorTypingActive = false;
794
839
  this.lastTypingPingAt = 0;
795
840
  this.launchCardOffered = false;
796
841
  this.resetPost();
842
+ (0, _localPreviews.clearLocalPreviews)();
843
+ (0, _useRetainedState.clearRetainedState)();
797
844
  this.setState({
798
845
  ...USER_SCOPED_STATE
799
846
  });
800
847
  const token = await this.client.getToken();
801
- await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY),
848
+ await Promise.all([this.client.clearToken(), _storage.SecureStorage.removeItem(_storage.USER_KEY), _storage.Storage.removeItem(_storage.UNREAD_KEY), _storage.Storage.removeItem(_storage.LAST_MESSAGE_KEY),
802
849
  // A new anonymous visitor on this device gets a new tracking identity.
803
850
  (0, _deviceId.rotateDeviceId)()]);
804
851
 
@@ -1178,10 +1225,11 @@ class WidgetStore {
1178
1225
 
1179
1226
  // Set inside the updater, read after: `setState` runs synchronously.
1180
1227
  let countedAsUnread = false;
1228
+ const rowFields = rowFieldsOf(message);
1181
1229
  this.setState(current => {
1182
1230
  const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
1183
1231
  ...conversation,
1184
- snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
1232
+ ...rowFields,
1185
1233
  lastMessageAt: message.createdAt,
1186
1234
  messageCount: conversation.messageCount + 1
1187
1235
  } : conversation) : current.conversations;
@@ -1219,11 +1267,12 @@ class WidgetStore {
1219
1267
  messageId,
1220
1268
  senderName: message.senderName || conversation?.agentName || null,
1221
1269
  senderAvatar: conversation?.agentAvatar ?? null,
1222
- preview: (message.bodyPlain ?? '').trim(),
1270
+ preview: plainTextOf(message),
1223
1271
  hasAttachments: (message.attachments?.length ?? 0) > 0
1224
1272
  }
1225
1273
  };
1226
1274
  });
1275
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1227
1276
 
1228
1277
  // The visitor is reading it live — move the server cursor too, or the
1229
1278
  // next list/init would badge a reply they already saw.
@@ -1321,10 +1370,112 @@ class WidgetStore {
1321
1370
  };
1322
1371
  }
1323
1372
  hydrateConversation(conversation) {
1324
- return this.applyConversationAgent({
1373
+ return this.applyConversationAgent(this.withLastMessage({
1325
1374
  ...conversation,
1326
1375
  agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1376
+ }));
1377
+ }
1378
+
1379
+ /**
1380
+ * Workstation's list doesn't say who sent the newest message, and calls one
1381
+ * without words "2 attachments". While a row still describes the message
1382
+ * this device saw last, both are filled in from it. `snippet` keeps the
1383
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1384
+ * `lastMessageAttachment` instead.
1385
+ */
1386
+ withLastMessage(conversation) {
1387
+ const known = this.lastMessages[conversation.id];
1388
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1389
+ // A list that says who sent it, or what it carried, is used as is.
1390
+ return {
1391
+ ...conversation,
1392
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1393
+ lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
1394
+ };
1395
+ }
1396
+
1397
+ /**
1398
+ * A send or a streamed message is the newest by arrival, whatever the two
1399
+ * clocks say; a history page only replaces something older. A sent message
1400
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1401
+ * page that has it replaces it: whatever is newest there came after it, and
1402
+ * Workstation's own time then stands in for the phone's.
1403
+ */
1404
+ rememberLastMessage(conversationId, at, fields, source, {
1405
+ messageId,
1406
+ pageIds
1407
+ } = {}) {
1408
+ const known = this.lastMessages[conversationId];
1409
+ if (source === 'history' && known) {
1410
+ const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
1411
+ if (outdated) return;
1412
+ }
1413
+ const hint = {
1414
+ at,
1415
+ fromVisitor: !!fields.lastMessageFromVisitor,
1416
+ attachment: fields.lastMessageAttachment ?? null,
1417
+ ...(source === 'sent' ? {
1418
+ phoneClock: true,
1419
+ ...(messageId ? {
1420
+ messageId
1421
+ } : {})
1422
+ } : {})
1423
+ };
1424
+ this.lastMessages = Object.fromEntries(Object.entries({
1425
+ ...this.lastMessages,
1426
+ [conversationId]: hint
1427
+ }).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
1428
+ // Until the stored hints are read, saving would overwrite them with these alone.
1429
+ if (this.lastMessagesLoaded) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1430
+ }
1431
+ async loadLastMessages(epoch) {
1432
+ if (this.lastMessagesLoaded) return;
1433
+ let stored = null;
1434
+ try {
1435
+ stored = JSON.parse((await _storage.Storage.getItem(_storage.LAST_MESSAGE_KEY)) ?? 'null');
1436
+ } catch {
1437
+ stored = null;
1438
+ }
1439
+ // Left unloaded: the init that replaces this one reads them again.
1440
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1441
+ this.lastMessagesLoaded = true;
1442
+ const learned = Object.keys(this.lastMessages).length > 0;
1443
+ if (stored && typeof stored === 'object') {
1444
+ for (const [conversationId, hint] of Object.entries(stored)) {
1445
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1446
+ const known = this.lastMessages[conversationId];
1447
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1448
+ this.lastMessages[conversationId] = {
1449
+ at: hint.at,
1450
+ fromVisitor: hint.fromVisitor,
1451
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1452
+ ...(hint.phoneClock === true ? {
1453
+ phoneClock: true
1454
+ } : {}),
1455
+ ...(typeof hint.messageId === 'string' ? {
1456
+ messageId: hint.messageId
1457
+ } : {})
1458
+ };
1459
+ }
1460
+ }
1461
+ // Hints learned while the stored ones were being read are saved with them.
1462
+ if (learned) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1463
+ }
1464
+
1465
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1466
+ learnLastMessage(conversationId, page) {
1467
+ let newest = null;
1468
+ for (const message of page) {
1469
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1470
+ }
1471
+ const fields = newest && rowFieldsOf(newest);
1472
+ if (!newest || !fields) return;
1473
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1474
+ pageIds: new Set(page.map(message => message.id))
1327
1475
  });
1476
+ this.setState(current => ({
1477
+ conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
1478
+ }));
1328
1479
  }
1329
1480
 
1330
1481
  /**
@@ -1333,16 +1484,22 @@ class WidgetStore {
1333
1484
  */
1334
1485
  hydrateAttachments(attachments, messageId) {
1335
1486
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1336
- const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => ({
1337
- ...attachment,
1338
- id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1339
- name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1340
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1341
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1342
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1343
- // then renders the name only, with nothing to open.
1344
- url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1345
- }));
1487
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
1488
+ const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1489
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1490
+ return {
1491
+ ...attachment,
1492
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1493
+ name,
1494
+ // Without a type (or with a generic one) the name says what it is,
1495
+ // so a photo still shows as a photo rather than a file row.
1496
+ contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : (0, _pickers.guessContentType)(name) ?? declared,
1497
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1498
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1499
+ // then renders the name only, with nothing to open.
1500
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1501
+ };
1502
+ });
1346
1503
  return hydrated.length > 0 ? hydrated : null;
1347
1504
  }
1348
1505
  clearTypingTimer() {
@@ -1598,10 +1755,10 @@ class WidgetStore {
1598
1755
  const result = await this.client.listMessages(conversationId);
1599
1756
  // Opening A then B quickly: A's page must never land under B.
1600
1757
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1758
+ const page = this.hydrateMessages(result.data);
1601
1759
  this.setState(current => {
1602
1760
  // Live replies and optimistic sends may have arrived since this page
1603
1761
  // was requested. Keep them, preferring the live copy for shared ids.
1604
- const page = this.hydrateMessages(result.data);
1605
1762
  const live = adoptOwnServerRows(current.messages, page);
1606
1763
  const liveIds = new Set(live.map(message => message.id));
1607
1764
  const history = page.filter(message => !liveIds.has(message.id));
@@ -1612,6 +1769,7 @@ class WidgetStore {
1612
1769
  messageCursor: result.meta.nextCursor
1613
1770
  };
1614
1771
  });
1772
+ this.learnLastMessage(conversationId, page);
1615
1773
  } catch {
1616
1774
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1617
1775
  this.setState({
@@ -1673,6 +1831,7 @@ class WidgetStore {
1673
1831
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1674
1832
  if (this.state.activeConversationId !== conversationId) return;
1675
1833
  const fresh = this.hydrateMessages(result.data);
1834
+ this.learnLastMessage(conversationId, fresh);
1676
1835
  this.setState(current => {
1677
1836
  const own = adoptOwnServerRows(current.messages, fresh);
1678
1837
  const known = new Set(own.map(message => message.id));
@@ -1839,6 +1998,15 @@ class WidgetStore {
1839
1998
  return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1840
1999
  }
1841
2000
 
2001
+ /**
2002
+ * Puts a picked file in storage while the visitor is still writing, so
2003
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
2004
+ * when that token expires.
2005
+ */
2006
+ uploadAttachment(file, signal) {
2007
+ return this.client.uploadAttachment(file, signal);
2008
+ }
2009
+
1842
2010
  /**
1843
2011
  * Re-sends a text bubble that failed. It moves to the end of the thread,
1844
2012
  * since it now goes out after anything sent meanwhile. Rejects like
@@ -1900,23 +2068,30 @@ class WidgetStore {
1900
2068
  }
1901
2069
  let createdId = null;
1902
2070
  try {
1903
- // Local files go up first; the message names each by its upload token.
2071
+ // Local files go up first, unless the composer already put them up; the
2072
+ // message names each by its upload token.
1904
2073
  const sentAttachments = await Promise.all(attachments.map(async ({
1905
2074
  name,
1906
2075
  url,
1907
2076
  fileUri,
2077
+ uploadToken,
1908
2078
  contentType,
1909
2079
  size
1910
- }) => fileUri ? {
2080
+ }) => uploadToken ? {
1911
2081
  name,
1912
2082
  contentType,
1913
2083
  size,
1914
- uploadToken: await this.client.uploadAttachment({
2084
+ uploadToken
2085
+ } : fileUri ? {
2086
+ name,
2087
+ contentType,
2088
+ size,
2089
+ uploadToken: (await this.client.uploadAttachment({
1915
2090
  uri: fileUri,
1916
2091
  name,
1917
2092
  contentType,
1918
2093
  size
1919
- })
2094
+ })).uploadToken
1920
2095
  } : {
1921
2096
  name,
1922
2097
  url,
@@ -1938,6 +2113,11 @@ class WidgetStore {
1938
2113
  });
1939
2114
  if (this.isStale(epoch)) return;
1940
2115
  if (ownCreation) createdId = result.conversationId;
2116
+
2117
+ // Workstation doesn't stream the visitor's own messages back, so the
2118
+ // list row learns about this one here.
2119
+ const sentAt = new Date().toISOString();
2120
+ const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
1941
2121
  this.setState(current => {
1942
2122
  // The stream or a catch-up fetch may have delivered this message
1943
2123
  // already — keep one copy, never two bubbles with the same id.
@@ -1947,18 +2127,27 @@ class WidgetStore {
1947
2127
  id: result.messageId,
1948
2128
  status: this.earlyStatuses.get(result.messageId) ?? 'sent'
1949
2129
  } : message);
1950
- if (!createdId) return {
1951
- messages
1952
- };
2130
+ if (!createdId) {
2131
+ return {
2132
+ messages,
2133
+ conversations: current.conversations.map(row => row.id === result.conversationId ? {
2134
+ ...row,
2135
+ ...sentRow,
2136
+ lastMessageAt: sentAt,
2137
+ messageCount: row.messageCount + (delivered ? 0 : 1)
2138
+ } : row)
2139
+ };
2140
+ }
1953
2141
  const known = current.conversations.some(item => item.id === result.conversationId);
1954
2142
  const conversation = {
1955
2143
  id: result.conversationId,
1956
2144
  subject: null,
1957
2145
  status: 'open',
1958
- snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2146
+ snippet: null,
2147
+ ...sentRow,
1959
2148
  messageCount: 1,
1960
- lastMessageAt: new Date().toISOString(),
1961
- createdAt: new Date().toISOString(),
2149
+ lastMessageAt: sentAt,
2150
+ createdAt: sentAt,
1962
2151
  agentName: null,
1963
2152
  agentAvatar: null
1964
2153
  };
@@ -1971,6 +2160,9 @@ class WidgetStore {
1971
2160
  } : {})
1972
2161
  };
1973
2162
  });
2163
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
2164
+ messageId: result.messageId
2165
+ });
1974
2166
 
1975
2167
  // New files to fetch the stored copy of: a fresh round of re-reads.
1976
2168
  const sent = this.state.messages.find(message => message.id === result.messageId);
@@ -2442,6 +2634,7 @@ class WidgetStore {
2442
2634
  // ─── Teardown (tests / hot reload) ──────────────────────────────────────
2443
2635
 
2444
2636
  destroy() {
2637
+ this.userUpdates.destroy();
2445
2638
  this.clearPostRecheck();
2446
2639
  this.clearPostThanks();
2447
2640
  this.realtime.close();
@@ -2494,6 +2687,65 @@ function asDeletedPlaceholder(message, deletedAt) {
2494
2687
  };
2495
2688
  }
2496
2689
 
2690
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2691
+ function plainTextOf(message) {
2692
+ return (message.bodyPlain || (0, _html.stripHtml)(message.body ?? '')).replace(/\s+/g, ' ').trim();
2693
+ }
2694
+
2695
+ /**
2696
+ * The newest message of a thread as this device saw it. `at` is its
2697
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2698
+ * message it's the phone's clock instead.
2699
+ */
2700
+
2701
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2702
+
2703
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2704
+ function rowFieldsOf(message) {
2705
+ if (message.messageType && message.messageType !== 'message') return null;
2706
+ return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
2707
+ }
2708
+
2709
+ /**
2710
+ * Its words, or with none, whether it carried only photos or other files too.
2711
+ * A message with neither (a card) still says who sent it, so a teammate's
2712
+ * card never leaves "You:" on the visitor's earlier message.
2713
+ */
2714
+ function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
2715
+ if (text) return {
2716
+ snippet: text,
2717
+ lastMessageAttachment: null,
2718
+ lastMessageFromVisitor: fromVisitor
2719
+ };
2720
+ const count = contentTypes.length;
2721
+ if (count === 0 && !inlinePhoto) return {
2722
+ lastMessageAttachment: null,
2723
+ lastMessageFromVisitor: fromVisitor
2724
+ };
2725
+ return {
2726
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2727
+ ...(count > 0 ? {
2728
+ snippet: `${count} attachment${count === 1 ? '' : 's'}`
2729
+ } : {}),
2730
+ lastMessageAttachment: contentTypes.every(type => (0, _pickers.isImageAttachment)(type)) ? 'photo' : 'file',
2731
+ lastMessageFromVisitor: fromVisitor
2732
+ };
2733
+ }
2734
+
2735
+ /**
2736
+ * The list stamps a conversation just after storing its message, so its time
2737
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2738
+ * A sent message is timed by the phone instead: a little drift either way
2739
+ * still matches, but only when the list rules out a team reply the visitor
2740
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2741
+ * there a sent message must match like any other.
2742
+ */
2743
+ function describesSameMessage(conversation, known) {
2744
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2745
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2746
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2747
+ }
2748
+
2497
2749
  /** A deletion missed while the stream was down shows up on the next re-read. */
2498
2750
  function adoptServerDeletions(messages, server) {
2499
2751
  const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
@@ -2541,13 +2793,31 @@ function mergeAttachments(local, server) {
2541
2793
  if (local.length !== server.length) {
2542
2794
  return server.every(attachment => attachment.url) ? server : local;
2543
2795
  }
2796
+ const owns = pairByName(local, server);
2544
2797
  const merged = server.map((attachment, index) => {
2545
- const own = local[index];
2546
- return isLocalAttachment(own) && attachment.url ? attachment : own;
2798
+ const own = owns[index];
2799
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2800
+ (0, _localPreviews.rememberLocalPreview)(attachment.url, own.url);
2801
+ return attachment;
2547
2802
  });
2548
2803
  return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2549
2804
  }
2550
2805
 
2806
+ /**
2807
+ * The local attachment each server one replaces. By name first: Workstation
2808
+ * may store a message's files in another order than they were sent, and a
2809
+ * photo paired with the wrong file would show it for the rest of the session.
2810
+ * Any it renamed pair up in the order left.
2811
+ */
2812
+ function pairByName(local, server) {
2813
+ const unpaired = [...local];
2814
+ const named = server.map(attachment => {
2815
+ const index = unpaired.findIndex(own => own.name === attachment.name);
2816
+ return index < 0 ? null : unpaired.splice(index, 1)[0];
2817
+ });
2818
+ return named.map(own => own ?? unpaired.shift());
2819
+ }
2820
+
2551
2821
  /**
2552
2822
  * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2553
2823
  * rows. Such a row replaces the pending or failed bubble it came from instead