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