@repzo/widget-react-native 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +137 -5
  2. package/README.md +64 -12
  3. package/lib/commonjs/api/client.js +96 -23
  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/locales.js +12 -3
  14. package/lib/commonjs/locales.js.map +1 -1
  15. package/lib/commonjs/state/widget-store.js +277 -30
  16. package/lib/commonjs/state/widget-store.js.map +1 -1
  17. package/lib/commonjs/storage.js +3 -1
  18. package/lib/commonjs/storage.js.map +1 -1
  19. package/lib/commonjs/types.js.map +1 -1
  20. package/lib/commonjs/ui/WidgetProvider.js +33 -13
  21. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  22. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  23. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  24. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  25. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  26. package/lib/commonjs/ui/components/ImageViewer.js +229 -24
  27. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  28. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  29. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  30. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  31. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  32. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  33. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  34. package/lib/commonjs/ui/screens/chat/ChatView.js +637 -164
  35. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  36. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
  37. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  38. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  39. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  40. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  41. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  42. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  43. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  44. package/lib/commonjs/ui/utils/html.js +13 -0
  45. package/lib/commonjs/ui/utils/html.js.map +1 -1
  46. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  47. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  48. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  49. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  50. package/lib/module/api/client.js +96 -23
  51. package/lib/module/api/client.js.map +1 -1
  52. package/lib/module/attachments/file-size.js +47 -0
  53. package/lib/module/attachments/file-size.js.map +1 -0
  54. package/lib/module/attachments/local-previews.js +33 -0
  55. package/lib/module/attachments/local-previews.js.map +1 -0
  56. package/lib/module/attachments/pickers.js +15 -25
  57. package/lib/module/attachments/pickers.js.map +1 -1
  58. package/lib/module/i18n.js +22 -3
  59. package/lib/module/i18n.js.map +1 -1
  60. package/lib/module/locales.js +12 -3
  61. package/lib/module/locales.js.map +1 -1
  62. package/lib/module/state/widget-store.js +279 -32
  63. package/lib/module/state/widget-store.js.map +1 -1
  64. package/lib/module/storage.js +2 -0
  65. package/lib/module/storage.js.map +1 -1
  66. package/lib/module/types.js.map +1 -1
  67. package/lib/module/ui/WidgetProvider.js +33 -13
  68. package/lib/module/ui/WidgetProvider.js.map +1 -1
  69. package/lib/module/ui/components/AttachmentChips.js +111 -42
  70. package/lib/module/ui/components/AttachmentChips.js.map +1 -1
  71. package/lib/module/ui/components/ConversationRow.js +65 -10
  72. package/lib/module/ui/components/ConversationRow.js.map +1 -1
  73. package/lib/module/ui/components/ImageViewer.js +231 -26
  74. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  75. package/lib/module/ui/components/RatingCard.js +5 -2
  76. package/lib/module/ui/components/RatingCard.js.map +1 -1
  77. package/lib/module/ui/components/ZoomableImage.js +58 -10
  78. package/lib/module/ui/components/ZoomableImage.js.map +1 -1
  79. package/lib/module/ui/hooks/use-retained-state.js +34 -0
  80. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  81. package/lib/module/ui/screens/chat/ChatView.js +641 -168
  82. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  83. package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
  84. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  85. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  86. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  87. package/lib/module/ui/screens/home/HomeView.js +40 -57
  88. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  89. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  90. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  91. package/lib/module/ui/utils/html.js +12 -0
  92. package/lib/module/ui/utils/html.js.map +1 -1
  93. package/lib/module/ui/utils/image-load-queue.js +109 -0
  94. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  95. package/lib/module/ui/utils/inline-attachments.js +85 -0
  96. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  97. package/lib/typescript/commonjs/api/client.d.ts +12 -4
  98. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  100. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  102. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
  104. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/i18n.d.ts +12 -3
  106. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/state/widget-store.d.ts +40 -1
  109. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/storage.d.ts +2 -0
  111. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/types.d.ts +41 -0
  113. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  116. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  118. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  120. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  123. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  125. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  127. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
  129. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +26 -1
  131. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  134. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  136. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  138. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  140. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  141. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  142. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  144. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  146. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  147. package/lib/typescript/module/api/client.d.ts +12 -4
  148. package/lib/typescript/module/api/client.d.ts.map +1 -1
  149. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  150. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  151. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  152. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  153. package/lib/typescript/module/attachments/pickers.d.ts +15 -2
  154. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  155. package/lib/typescript/module/i18n.d.ts +12 -3
  156. package/lib/typescript/module/i18n.d.ts.map +1 -1
  157. package/lib/typescript/module/locales.d.ts.map +1 -1
  158. package/lib/typescript/module/state/widget-store.d.ts +40 -1
  159. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  160. package/lib/typescript/module/storage.d.ts +2 -0
  161. package/lib/typescript/module/storage.d.ts.map +1 -1
  162. package/lib/typescript/module/types.d.ts +41 -0
  163. package/lib/typescript/module/types.d.ts.map +1 -1
  164. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  165. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  166. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  167. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  168. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  169. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  170. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  171. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  172. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  173. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  174. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  175. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  176. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  177. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
  179. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  180. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +26 -1
  181. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  182. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  184. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  186. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  187. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  188. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  189. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  190. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  191. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  192. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  193. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  194. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  195. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  196. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  197. package/package.json +1 -1
  198. package/src/api/client.ts +116 -34
  199. package/src/attachments/file-size.ts +42 -0
  200. package/src/attachments/local-previews.ts +32 -0
  201. package/src/attachments/pickers.ts +23 -25
  202. package/src/i18n.ts +22 -3
  203. package/src/locales.ts +12 -3
  204. package/src/state/widget-store.ts +322 -50
  205. package/src/storage.ts +2 -0
  206. package/src/types.ts +41 -0
  207. package/src/ui/WidgetProvider.tsx +35 -8
  208. package/src/ui/components/AttachmentChips.tsx +100 -29
  209. package/src/ui/components/ConversationRow.tsx +90 -9
  210. package/src/ui/components/ImageViewer.tsx +242 -24
  211. package/src/ui/components/RatingCard.tsx +5 -2
  212. package/src/ui/components/ZoomableImage.tsx +58 -5
  213. package/src/ui/hooks/use-retained-state.ts +35 -0
  214. package/src/ui/screens/chat/ChatView.styles.ts +139 -1
  215. package/src/ui/screens/chat/ChatView.tsx +721 -197
  216. package/src/ui/screens/chat/ChatView.types.ts +26 -1
  217. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  218. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  219. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  220. package/src/ui/screens/home/HomeView.tsx +54 -70
  221. package/src/ui/screens/home/HomeView.types.ts +5 -6
  222. package/src/ui/utils/html.ts +26 -0
  223. package/src/ui/utils/image-load-queue.ts +108 -0
  224. package/src/ui/utils/inline-attachments.ts +71 -0
@@ -11,9 +11,12 @@ 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");
18
21
  /**
19
22
  * The widget's single source of truth.
@@ -26,7 +29,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
26
29
  */
27
30
 
28
31
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
29
- const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
32
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
30
33
 
31
34
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
32
35
  function sessionPlatform() {
@@ -41,6 +44,12 @@ const TYPING_SETTLE_MS = 3_000;
41
44
  const TYPING_PING_INTERVAL_MS = 4_000;
42
45
  /** Guards against the SSE stream replaying messages after a reconnect. */
43
46
  const SEEN_MESSAGE_LIMIT = 100;
47
+ /** Threads whose newest message is remembered across launches. */
48
+ const LAST_MESSAGE_LIMIT = 50;
49
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
50
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
51
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
52
+ const LIST_LAG_MS = 1_000;
44
53
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
45
54
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
46
55
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -134,6 +143,10 @@ class WidgetStore {
134
143
 
135
144
  /** `boot({ posts })` — Send posts over the app. */
136
145
  postsEnabled = true;
146
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
147
+ photoAlbumsEnabled = true;
148
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
149
+ youPrefixEnabled = true;
137
150
 
138
151
  /** Posts whose view was reported this session (the server also dedupes per day). */
139
152
  viewedPostIds = new Set();
@@ -188,6 +201,10 @@ class WidgetStore {
188
201
  lastTypingPingAt = 0;
189
202
  visitorTypingActive = false;
190
203
  seenMessageIds = [];
204
+
205
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
206
+ lastMessages = {};
207
+ lastMessagesLoaded = false;
191
208
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
192
209
  earlyStatuses = new Map();
193
210
  /** The agent UI action behind a failed bubble, so Retry runs it again too. */
@@ -267,6 +284,8 @@ class WidgetStore {
267
284
 
268
285
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
269
286
  getThreadGeneration = () => this.threadGeneration;
287
+ showsPhotoAlbums = () => this.photoAlbumsEnabled;
288
+ showsYouPrefix = () => this.youPrefixEnabled;
270
289
  setState(patch) {
271
290
  const previous = this.state;
272
291
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -338,6 +357,8 @@ class WidgetStore {
338
357
  if (options.inAppNotifications !== undefined) {
339
358
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
340
359
  }
360
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
361
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
341
362
  if (options.posts !== undefined) {
342
363
  const wasEnabled = this.postsEnabled;
343
364
  this.postsEnabled = options.posts !== false;
@@ -492,13 +513,13 @@ class WidgetStore {
492
513
  // queued op must never see one without the other.
493
514
  await Promise.all([this.client.setToken(result.token), userKey ? _storage.SecureStorage.setItem(_storage.USER_KEY, userKey) : _storage.SecureStorage.removeItem(_storage.USER_KEY)]);
494
515
  if (this.isStale(epoch)) return;
495
- const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
496
516
 
497
517
  // Server counts win (they include replies pushed while the app was
498
518
  // killed); the locally persisted map only covers older Workstation
499
519
  // deployments that don't send unreadCount yet.
500
- const storedUnread = await this.loadUnread();
520
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
501
521
  if (this.isStale(epoch)) return;
522
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
502
523
  const unreadByConversation = {};
503
524
  for (const conversation of conversations) {
504
525
  const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
@@ -789,16 +810,20 @@ class WidgetStore {
789
810
  this.userKeyLoaded = true;
790
811
  this.pendingAgentByConversation = {};
791
812
  this.seenMessageIds = [];
813
+ this.lastMessages = {};
814
+ this.lastMessagesLoaded = false;
792
815
  this.earlyStatuses.clear();
793
816
  this.visitorTypingActive = false;
794
817
  this.lastTypingPingAt = 0;
795
818
  this.launchCardOffered = false;
796
819
  this.resetPost();
820
+ (0, _localPreviews.clearLocalPreviews)();
821
+ (0, _useRetainedState.clearRetainedState)();
797
822
  this.setState({
798
823
  ...USER_SCOPED_STATE
799
824
  });
800
825
  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),
826
+ 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
827
  // A new anonymous visitor on this device gets a new tracking identity.
803
828
  (0, _deviceId.rotateDeviceId)()]);
804
829
 
@@ -1178,10 +1203,11 @@ class WidgetStore {
1178
1203
 
1179
1204
  // Set inside the updater, read after: `setState` runs synchronously.
1180
1205
  let countedAsUnread = false;
1206
+ const rowFields = rowFieldsOf(message);
1181
1207
  this.setState(current => {
1182
1208
  const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
1183
1209
  ...conversation,
1184
- snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
1210
+ ...rowFields,
1185
1211
  lastMessageAt: message.createdAt,
1186
1212
  messageCount: conversation.messageCount + 1
1187
1213
  } : conversation) : current.conversations;
@@ -1219,11 +1245,12 @@ class WidgetStore {
1219
1245
  messageId,
1220
1246
  senderName: message.senderName || conversation?.agentName || null,
1221
1247
  senderAvatar: conversation?.agentAvatar ?? null,
1222
- preview: (message.bodyPlain ?? '').trim(),
1248
+ preview: plainTextOf(message),
1223
1249
  hasAttachments: (message.attachments?.length ?? 0) > 0
1224
1250
  }
1225
1251
  };
1226
1252
  });
1253
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1227
1254
 
1228
1255
  // The visitor is reading it live — move the server cursor too, or the
1229
1256
  // next list/init would badge a reply they already saw.
@@ -1321,10 +1348,112 @@ class WidgetStore {
1321
1348
  };
1322
1349
  }
1323
1350
  hydrateConversation(conversation) {
1324
- return this.applyConversationAgent({
1351
+ return this.applyConversationAgent(this.withLastMessage({
1325
1352
  ...conversation,
1326
1353
  agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1354
+ }));
1355
+ }
1356
+
1357
+ /**
1358
+ * Workstation's list doesn't say who sent the newest message, and calls one
1359
+ * without words "2 attachments". While a row still describes the message
1360
+ * this device saw last, both are filled in from it. `snippet` keeps the
1361
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1362
+ * `lastMessageAttachment` instead.
1363
+ */
1364
+ withLastMessage(conversation) {
1365
+ const known = this.lastMessages[conversation.id];
1366
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1367
+ // A list that says who sent it, or what it carried, is used as is.
1368
+ return {
1369
+ ...conversation,
1370
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1371
+ lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
1372
+ };
1373
+ }
1374
+
1375
+ /**
1376
+ * A send or a streamed message is the newest by arrival, whatever the two
1377
+ * clocks say; a history page only replaces something older. A sent message
1378
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1379
+ * page that has it replaces it: whatever is newest there came after it, and
1380
+ * Workstation's own time then stands in for the phone's.
1381
+ */
1382
+ rememberLastMessage(conversationId, at, fields, source, {
1383
+ messageId,
1384
+ pageIds
1385
+ } = {}) {
1386
+ const known = this.lastMessages[conversationId];
1387
+ if (source === 'history' && known) {
1388
+ const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
1389
+ if (outdated) return;
1390
+ }
1391
+ const hint = {
1392
+ at,
1393
+ fromVisitor: !!fields.lastMessageFromVisitor,
1394
+ attachment: fields.lastMessageAttachment ?? null,
1395
+ ...(source === 'sent' ? {
1396
+ phoneClock: true,
1397
+ ...(messageId ? {
1398
+ messageId
1399
+ } : {})
1400
+ } : {})
1401
+ };
1402
+ this.lastMessages = Object.fromEntries(Object.entries({
1403
+ ...this.lastMessages,
1404
+ [conversationId]: hint
1405
+ }).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
1406
+ // Until the stored hints are read, saving would overwrite them with these alone.
1407
+ if (this.lastMessagesLoaded) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1408
+ }
1409
+ async loadLastMessages(epoch) {
1410
+ if (this.lastMessagesLoaded) return;
1411
+ let stored = null;
1412
+ try {
1413
+ stored = JSON.parse((await _storage.Storage.getItem(_storage.LAST_MESSAGE_KEY)) ?? 'null');
1414
+ } catch {
1415
+ stored = null;
1416
+ }
1417
+ // Left unloaded: the init that replaces this one reads them again.
1418
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1419
+ this.lastMessagesLoaded = true;
1420
+ const learned = Object.keys(this.lastMessages).length > 0;
1421
+ if (stored && typeof stored === 'object') {
1422
+ for (const [conversationId, hint] of Object.entries(stored)) {
1423
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1424
+ const known = this.lastMessages[conversationId];
1425
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1426
+ this.lastMessages[conversationId] = {
1427
+ at: hint.at,
1428
+ fromVisitor: hint.fromVisitor,
1429
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1430
+ ...(hint.phoneClock === true ? {
1431
+ phoneClock: true
1432
+ } : {}),
1433
+ ...(typeof hint.messageId === 'string' ? {
1434
+ messageId: hint.messageId
1435
+ } : {})
1436
+ };
1437
+ }
1438
+ }
1439
+ // Hints learned while the stored ones were being read are saved with them.
1440
+ if (learned) void _storage.Storage.setItem(_storage.LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1441
+ }
1442
+
1443
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1444
+ learnLastMessage(conversationId, page) {
1445
+ let newest = null;
1446
+ for (const message of page) {
1447
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1448
+ }
1449
+ const fields = newest && rowFieldsOf(newest);
1450
+ if (!newest || !fields) return;
1451
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1452
+ pageIds: new Set(page.map(message => message.id))
1327
1453
  });
1454
+ this.setState(current => ({
1455
+ conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
1456
+ }));
1328
1457
  }
1329
1458
 
1330
1459
  /**
@@ -1333,16 +1462,22 @@ class WidgetStore {
1333
1462
  */
1334
1463
  hydrateAttachments(attachments, messageId) {
1335
1464
  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
- }));
1465
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
1466
+ const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1467
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1468
+ return {
1469
+ ...attachment,
1470
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1471
+ name,
1472
+ // Without a type (or with a generic one) the name says what it is,
1473
+ // so a photo still shows as a photo rather than a file row.
1474
+ contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : (0, _pickers.guessContentType)(name) ?? declared,
1475
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1476
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1477
+ // then renders the name only, with nothing to open.
1478
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1479
+ };
1480
+ });
1346
1481
  return hydrated.length > 0 ? hydrated : null;
1347
1482
  }
1348
1483
  clearTypingTimer() {
@@ -1598,10 +1733,10 @@ class WidgetStore {
1598
1733
  const result = await this.client.listMessages(conversationId);
1599
1734
  // Opening A then B quickly: A's page must never land under B.
1600
1735
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1736
+ const page = this.hydrateMessages(result.data);
1601
1737
  this.setState(current => {
1602
1738
  // Live replies and optimistic sends may have arrived since this page
1603
1739
  // was requested. Keep them, preferring the live copy for shared ids.
1604
- const page = this.hydrateMessages(result.data);
1605
1740
  const live = adoptOwnServerRows(current.messages, page);
1606
1741
  const liveIds = new Set(live.map(message => message.id));
1607
1742
  const history = page.filter(message => !liveIds.has(message.id));
@@ -1612,6 +1747,7 @@ class WidgetStore {
1612
1747
  messageCursor: result.meta.nextCursor
1613
1748
  };
1614
1749
  });
1750
+ this.learnLastMessage(conversationId, page);
1615
1751
  } catch {
1616
1752
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1617
1753
  this.setState({
@@ -1673,6 +1809,7 @@ class WidgetStore {
1673
1809
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1674
1810
  if (this.state.activeConversationId !== conversationId) return;
1675
1811
  const fresh = this.hydrateMessages(result.data);
1812
+ this.learnLastMessage(conversationId, fresh);
1676
1813
  this.setState(current => {
1677
1814
  const own = adoptOwnServerRows(current.messages, fresh);
1678
1815
  const known = new Set(own.map(message => message.id));
@@ -1839,6 +1976,15 @@ class WidgetStore {
1839
1976
  return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1840
1977
  }
1841
1978
 
1979
+ /**
1980
+ * Puts a picked file in storage while the visitor is still writing, so
1981
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
1982
+ * when that token expires.
1983
+ */
1984
+ uploadAttachment(file, signal) {
1985
+ return this.client.uploadAttachment(file, signal);
1986
+ }
1987
+
1842
1988
  /**
1843
1989
  * Re-sends a text bubble that failed. It moves to the end of the thread,
1844
1990
  * since it now goes out after anything sent meanwhile. Rejects like
@@ -1900,23 +2046,30 @@ class WidgetStore {
1900
2046
  }
1901
2047
  let createdId = null;
1902
2048
  try {
1903
- // Local files go up first; the message names each by its upload token.
2049
+ // Local files go up first, unless the composer already put them up; the
2050
+ // message names each by its upload token.
1904
2051
  const sentAttachments = await Promise.all(attachments.map(async ({
1905
2052
  name,
1906
2053
  url,
1907
2054
  fileUri,
2055
+ uploadToken,
1908
2056
  contentType,
1909
2057
  size
1910
- }) => fileUri ? {
2058
+ }) => uploadToken ? {
2059
+ name,
2060
+ contentType,
2061
+ size,
2062
+ uploadToken
2063
+ } : fileUri ? {
1911
2064
  name,
1912
2065
  contentType,
1913
2066
  size,
1914
- uploadToken: await this.client.uploadAttachment({
2067
+ uploadToken: (await this.client.uploadAttachment({
1915
2068
  uri: fileUri,
1916
2069
  name,
1917
2070
  contentType,
1918
2071
  size
1919
- })
2072
+ })).uploadToken
1920
2073
  } : {
1921
2074
  name,
1922
2075
  url,
@@ -1938,6 +2091,11 @@ class WidgetStore {
1938
2091
  });
1939
2092
  if (this.isStale(epoch)) return;
1940
2093
  if (ownCreation) createdId = result.conversationId;
2094
+
2095
+ // Workstation doesn't stream the visitor's own messages back, so the
2096
+ // list row learns about this one here.
2097
+ const sentAt = new Date().toISOString();
2098
+ const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
1941
2099
  this.setState(current => {
1942
2100
  // The stream or a catch-up fetch may have delivered this message
1943
2101
  // already — keep one copy, never two bubbles with the same id.
@@ -1947,18 +2105,27 @@ class WidgetStore {
1947
2105
  id: result.messageId,
1948
2106
  status: this.earlyStatuses.get(result.messageId) ?? 'sent'
1949
2107
  } : message);
1950
- if (!createdId) return {
1951
- messages
1952
- };
2108
+ if (!createdId) {
2109
+ return {
2110
+ messages,
2111
+ conversations: current.conversations.map(row => row.id === result.conversationId ? {
2112
+ ...row,
2113
+ ...sentRow,
2114
+ lastMessageAt: sentAt,
2115
+ messageCount: row.messageCount + (delivered ? 0 : 1)
2116
+ } : row)
2117
+ };
2118
+ }
1953
2119
  const known = current.conversations.some(item => item.id === result.conversationId);
1954
2120
  const conversation = {
1955
2121
  id: result.conversationId,
1956
2122
  subject: null,
1957
2123
  status: 'open',
1958
- snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2124
+ snippet: null,
2125
+ ...sentRow,
1959
2126
  messageCount: 1,
1960
- lastMessageAt: new Date().toISOString(),
1961
- createdAt: new Date().toISOString(),
2127
+ lastMessageAt: sentAt,
2128
+ createdAt: sentAt,
1962
2129
  agentName: null,
1963
2130
  agentAvatar: null
1964
2131
  };
@@ -1971,6 +2138,9 @@ class WidgetStore {
1971
2138
  } : {})
1972
2139
  };
1973
2140
  });
2141
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
2142
+ messageId: result.messageId
2143
+ });
1974
2144
 
1975
2145
  // New files to fetch the stored copy of: a fresh round of re-reads.
1976
2146
  const sent = this.state.messages.find(message => message.id === result.messageId);
@@ -2494,6 +2664,65 @@ function asDeletedPlaceholder(message, deletedAt) {
2494
2664
  };
2495
2665
  }
2496
2666
 
2667
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2668
+ function plainTextOf(message) {
2669
+ return (message.bodyPlain || (0, _html.stripHtml)(message.body ?? '')).replace(/\s+/g, ' ').trim();
2670
+ }
2671
+
2672
+ /**
2673
+ * The newest message of a thread as this device saw it. `at` is its
2674
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2675
+ * message it's the phone's clock instead.
2676
+ */
2677
+
2678
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2679
+
2680
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2681
+ function rowFieldsOf(message) {
2682
+ if (message.messageType && message.messageType !== 'message') return null;
2683
+ return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
2684
+ }
2685
+
2686
+ /**
2687
+ * Its words, or with none, whether it carried only photos or other files too.
2688
+ * A message with neither (a card) still says who sent it, so a teammate's
2689
+ * card never leaves "You:" on the visitor's earlier message.
2690
+ */
2691
+ function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
2692
+ if (text) return {
2693
+ snippet: text,
2694
+ lastMessageAttachment: null,
2695
+ lastMessageFromVisitor: fromVisitor
2696
+ };
2697
+ const count = contentTypes.length;
2698
+ if (count === 0 && !inlinePhoto) return {
2699
+ lastMessageAttachment: null,
2700
+ lastMessageFromVisitor: fromVisitor
2701
+ };
2702
+ return {
2703
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2704
+ ...(count > 0 ? {
2705
+ snippet: `${count} attachment${count === 1 ? '' : 's'}`
2706
+ } : {}),
2707
+ lastMessageAttachment: contentTypes.every(type => (0, _pickers.isImageAttachment)(type)) ? 'photo' : 'file',
2708
+ lastMessageFromVisitor: fromVisitor
2709
+ };
2710
+ }
2711
+
2712
+ /**
2713
+ * The list stamps a conversation just after storing its message, so its time
2714
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2715
+ * A sent message is timed by the phone instead: a little drift either way
2716
+ * still matches, but only when the list rules out a team reply the visitor
2717
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2718
+ * there a sent message must match like any other.
2719
+ */
2720
+ function describesSameMessage(conversation, known) {
2721
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2722
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2723
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2724
+ }
2725
+
2497
2726
  /** A deletion missed while the stream was down shows up on the next re-read. */
2498
2727
  function adoptServerDeletions(messages, server) {
2499
2728
  const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
@@ -2541,13 +2770,31 @@ function mergeAttachments(local, server) {
2541
2770
  if (local.length !== server.length) {
2542
2771
  return server.every(attachment => attachment.url) ? server : local;
2543
2772
  }
2773
+ const owns = pairByName(local, server);
2544
2774
  const merged = server.map((attachment, index) => {
2545
- const own = local[index];
2546
- return isLocalAttachment(own) && attachment.url ? attachment : own;
2775
+ const own = owns[index];
2776
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2777
+ (0, _localPreviews.rememberLocalPreview)(attachment.url, own.url);
2778
+ return attachment;
2547
2779
  });
2548
2780
  return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2549
2781
  }
2550
2782
 
2783
+ /**
2784
+ * The local attachment each server one replaces. By name first: Workstation
2785
+ * may store a message's files in another order than they were sent, and a
2786
+ * photo paired with the wrong file would show it for the rest of the session.
2787
+ * Any it renamed pair up in the order left.
2788
+ */
2789
+ function pairByName(local, server) {
2790
+ const unpaired = [...local];
2791
+ const named = server.map(attachment => {
2792
+ const index = unpaired.findIndex(own => own.name === attachment.name);
2793
+ return index < 0 ? null : unpaired.splice(index, 1)[0];
2794
+ });
2795
+ return named.map(own => own ?? unpaired.shift());
2796
+ }
2797
+
2551
2798
  /**
2552
2799
  * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2553
2800
  * rows. Such a row replaces the pending or failed bubble it came from instead