@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
@@ -16,14 +16,17 @@ 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";
24
27
 
25
28
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
26
- const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
29
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
27
30
 
28
31
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
29
32
  function sessionPlatform() {
@@ -38,6 +41,12 @@ const TYPING_SETTLE_MS = 3_000;
38
41
  const TYPING_PING_INTERVAL_MS = 4_000;
39
42
  /** Guards against the SSE stream replaying messages after a reconnect. */
40
43
  const SEEN_MESSAGE_LIMIT = 100;
44
+ /** Threads whose newest message is remembered across launches. */
45
+ const LAST_MESSAGE_LIMIT = 50;
46
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
47
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
48
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
49
+ const LIST_LAG_MS = 1_000;
41
50
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
42
51
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
43
52
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -131,6 +140,10 @@ export class WidgetStore {
131
140
 
132
141
  /** `boot({ posts })` — Send posts over the app. */
133
142
  postsEnabled = true;
143
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
144
+ photoAlbumsEnabled = true;
145
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
146
+ youPrefixEnabled = true;
134
147
 
135
148
  /** Posts whose view was reported this session (the server also dedupes per day). */
136
149
  viewedPostIds = new Set();
@@ -185,6 +198,10 @@ export class WidgetStore {
185
198
  lastTypingPingAt = 0;
186
199
  visitorTypingActive = false;
187
200
  seenMessageIds = [];
201
+
202
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
203
+ lastMessages = {};
204
+ lastMessagesLoaded = false;
188
205
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
189
206
  earlyStatuses = new Map();
190
207
  /** The agent UI action behind a failed bubble, so Retry runs it again too. */
@@ -264,6 +281,8 @@ export class WidgetStore {
264
281
 
265
282
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
266
283
  getThreadGeneration = () => this.threadGeneration;
284
+ showsPhotoAlbums = () => this.photoAlbumsEnabled;
285
+ showsYouPrefix = () => this.youPrefixEnabled;
267
286
  setState(patch) {
268
287
  const previous = this.state;
269
288
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -335,6 +354,8 @@ export class WidgetStore {
335
354
  if (options.inAppNotifications !== undefined) {
336
355
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
337
356
  }
357
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
358
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
338
359
  if (options.posts !== undefined) {
339
360
  const wasEnabled = this.postsEnabled;
340
361
  this.postsEnabled = options.posts !== false;
@@ -489,13 +510,13 @@ export class WidgetStore {
489
510
  // queued op must never see one without the other.
490
511
  await Promise.all([this.client.setToken(result.token), userKey ? SecureStorage.setItem(USER_KEY, userKey) : SecureStorage.removeItem(USER_KEY)]);
491
512
  if (this.isStale(epoch)) return;
492
- const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
493
513
 
494
514
  // Server counts win (they include replies pushed while the app was
495
515
  // killed); the locally persisted map only covers older Workstation
496
516
  // deployments that don't send unreadCount yet.
497
- const storedUnread = await this.loadUnread();
517
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
498
518
  if (this.isStale(epoch)) return;
519
+ const conversations = result.conversations.map(conversation => this.hydrateConversation(conversation));
499
520
  const unreadByConversation = {};
500
521
  for (const conversation of conversations) {
501
522
  const count = typeof conversation.unreadCount === 'number' ? conversation.unreadCount : storedUnread[conversation.id] ?? 0;
@@ -786,16 +807,20 @@ export class WidgetStore {
786
807
  this.userKeyLoaded = true;
787
808
  this.pendingAgentByConversation = {};
788
809
  this.seenMessageIds = [];
810
+ this.lastMessages = {};
811
+ this.lastMessagesLoaded = false;
789
812
  this.earlyStatuses.clear();
790
813
  this.visitorTypingActive = false;
791
814
  this.lastTypingPingAt = 0;
792
815
  this.launchCardOffered = false;
793
816
  this.resetPost();
817
+ clearLocalPreviews();
818
+ clearRetainedState();
794
819
  this.setState({
795
820
  ...USER_SCOPED_STATE
796
821
  });
797
822
  const token = await this.client.getToken();
798
- await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY),
823
+ await Promise.all([this.client.clearToken(), SecureStorage.removeItem(USER_KEY), Storage.removeItem(UNREAD_KEY), Storage.removeItem(LAST_MESSAGE_KEY),
799
824
  // A new anonymous visitor on this device gets a new tracking identity.
800
825
  rotateDeviceId()]);
801
826
 
@@ -1175,10 +1200,11 @@ export class WidgetStore {
1175
1200
 
1176
1201
  // Set inside the updater, read after: `setState` runs synchronously.
1177
1202
  let countedAsUnread = false;
1203
+ const rowFields = rowFieldsOf(message);
1178
1204
  this.setState(current => {
1179
1205
  const conversations = conversationId ? current.conversations.map(conversation => conversation.id === conversationId ? {
1180
1206
  ...conversation,
1181
- snippet: message.messageType === 'message' ? message.bodyPlain || conversation.snippet : conversation.snippet,
1207
+ ...rowFields,
1182
1208
  lastMessageAt: message.createdAt,
1183
1209
  messageCount: conversation.messageCount + 1
1184
1210
  } : conversation) : current.conversations;
@@ -1216,11 +1242,12 @@ export class WidgetStore {
1216
1242
  messageId,
1217
1243
  senderName: message.senderName || conversation?.agentName || null,
1218
1244
  senderAvatar: conversation?.agentAvatar ?? null,
1219
- preview: (message.bodyPlain ?? '').trim(),
1245
+ preview: plainTextOf(message),
1220
1246
  hasAttachments: (message.attachments?.length ?? 0) > 0
1221
1247
  }
1222
1248
  };
1223
1249
  });
1250
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1224
1251
 
1225
1252
  // The visitor is reading it live — move the server cursor too, or the
1226
1253
  // next list/init would badge a reply they already saw.
@@ -1318,10 +1345,112 @@ export class WidgetStore {
1318
1345
  };
1319
1346
  }
1320
1347
  hydrateConversation(conversation) {
1321
- return this.applyConversationAgent({
1348
+ return this.applyConversationAgent(this.withLastMessage({
1322
1349
  ...conversation,
1323
1350
  agentAvatar: this.client.resolveUrl(conversation.agentAvatar)
1351
+ }));
1352
+ }
1353
+
1354
+ /**
1355
+ * Workstation's list doesn't say who sent the newest message, and calls one
1356
+ * without words "2 attachments". While a row still describes the message
1357
+ * this device saw last, both are filled in from it. `snippet` keeps the
1358
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1359
+ * `lastMessageAttachment` instead.
1360
+ */
1361
+ withLastMessage(conversation) {
1362
+ const known = this.lastMessages[conversation.id];
1363
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1364
+ // A list that says who sent it, or what it carried, is used as is.
1365
+ return {
1366
+ ...conversation,
1367
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1368
+ lastMessageAttachment: conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment
1369
+ };
1370
+ }
1371
+
1372
+ /**
1373
+ * A send or a streamed message is the newest by arrival, whatever the two
1374
+ * clocks say; a history page only replaces something older. A sent message
1375
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1376
+ * page that has it replaces it: whatever is newest there came after it, and
1377
+ * Workstation's own time then stands in for the phone's.
1378
+ */
1379
+ rememberLastMessage(conversationId, at, fields, source, {
1380
+ messageId,
1381
+ pageIds
1382
+ } = {}) {
1383
+ const known = this.lastMessages[conversationId];
1384
+ if (source === 'history' && known) {
1385
+ const outdated = known.phoneClock && known.messageId ? !pageIds?.has(known.messageId) : Date.parse(known.at) > Date.parse(at);
1386
+ if (outdated) return;
1387
+ }
1388
+ const hint = {
1389
+ at,
1390
+ fromVisitor: !!fields.lastMessageFromVisitor,
1391
+ attachment: fields.lastMessageAttachment ?? null,
1392
+ ...(source === 'sent' ? {
1393
+ phoneClock: true,
1394
+ ...(messageId ? {
1395
+ messageId
1396
+ } : {})
1397
+ } : {})
1398
+ };
1399
+ this.lastMessages = Object.fromEntries(Object.entries({
1400
+ ...this.lastMessages,
1401
+ [conversationId]: hint
1402
+ }).sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at)).slice(0, LAST_MESSAGE_LIMIT));
1403
+ // Until the stored hints are read, saving would overwrite them with these alone.
1404
+ if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1405
+ }
1406
+ async loadLastMessages(epoch) {
1407
+ if (this.lastMessagesLoaded) return;
1408
+ let stored = null;
1409
+ try {
1410
+ stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
1411
+ } catch {
1412
+ stored = null;
1413
+ }
1414
+ // Left unloaded: the init that replaces this one reads them again.
1415
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1416
+ this.lastMessagesLoaded = true;
1417
+ const learned = Object.keys(this.lastMessages).length > 0;
1418
+ if (stored && typeof stored === 'object') {
1419
+ for (const [conversationId, hint] of Object.entries(stored)) {
1420
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1421
+ const known = this.lastMessages[conversationId];
1422
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1423
+ this.lastMessages[conversationId] = {
1424
+ at: hint.at,
1425
+ fromVisitor: hint.fromVisitor,
1426
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1427
+ ...(hint.phoneClock === true ? {
1428
+ phoneClock: true
1429
+ } : {}),
1430
+ ...(typeof hint.messageId === 'string' ? {
1431
+ messageId: hint.messageId
1432
+ } : {})
1433
+ };
1434
+ }
1435
+ }
1436
+ // Hints learned while the stored ones were being read are saved with them.
1437
+ if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1438
+ }
1439
+
1440
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1441
+ learnLastMessage(conversationId, page) {
1442
+ let newest = null;
1443
+ for (const message of page) {
1444
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1445
+ }
1446
+ const fields = newest && rowFieldsOf(newest);
1447
+ if (!newest || !fields) return;
1448
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1449
+ pageIds: new Set(page.map(message => message.id))
1324
1450
  });
1451
+ this.setState(current => ({
1452
+ conversations: current.conversations.map(row => row.id === conversationId ? this.withLastMessage(row) : row)
1453
+ }));
1325
1454
  }
1326
1455
 
1327
1456
  /**
@@ -1330,16 +1459,22 @@ export class WidgetStore {
1330
1459
  */
1331
1460
  hydrateAttachments(attachments, messageId) {
1332
1461
  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
- }));
1462
+ const hydrated = attachments.filter(item => !!item && typeof item === 'object').map((attachment, index) => {
1463
+ const name = typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1464
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1465
+ return {
1466
+ ...attachment,
1467
+ id: typeof attachment.id === 'string' || typeof attachment.id === 'number' ? String(attachment.id) : `${messageId ?? 'attachment'}-${index}`,
1468
+ name,
1469
+ // Without a type (or with a generic one) the name says what it is,
1470
+ // so a photo still shows as a photo rather than a file row.
1471
+ contentType: declared && !/^application\/octet-stream$/i.test(declared) ? declared : guessContentType(name) ?? declared,
1472
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1473
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1474
+ // then renders the name only, with nothing to open.
1475
+ url: this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? ''
1476
+ };
1477
+ });
1343
1478
  return hydrated.length > 0 ? hydrated : null;
1344
1479
  }
1345
1480
  clearTypingTimer() {
@@ -1595,10 +1730,10 @@ export class WidgetStore {
1595
1730
  const result = await this.client.listMessages(conversationId);
1596
1731
  // Opening A then B quickly: A's page must never land under B.
1597
1732
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1733
+ const page = this.hydrateMessages(result.data);
1598
1734
  this.setState(current => {
1599
1735
  // Live replies and optimistic sends may have arrived since this page
1600
1736
  // was requested. Keep them, preferring the live copy for shared ids.
1601
- const page = this.hydrateMessages(result.data);
1602
1737
  const live = adoptOwnServerRows(current.messages, page);
1603
1738
  const liveIds = new Set(live.map(message => message.id));
1604
1739
  const history = page.filter(message => !liveIds.has(message.id));
@@ -1609,6 +1744,7 @@ export class WidgetStore {
1609
1744
  messageCursor: result.meta.nextCursor
1610
1745
  };
1611
1746
  });
1747
+ this.learnLastMessage(conversationId, page);
1612
1748
  } catch {
1613
1749
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1614
1750
  this.setState({
@@ -1670,6 +1806,7 @@ export class WidgetStore {
1670
1806
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1671
1807
  if (this.state.activeConversationId !== conversationId) return;
1672
1808
  const fresh = this.hydrateMessages(result.data);
1809
+ this.learnLastMessage(conversationId, fresh);
1673
1810
  this.setState(current => {
1674
1811
  const own = adoptOwnServerRows(current.messages, fresh);
1675
1812
  const known = new Set(own.map(message => message.id));
@@ -1836,6 +1973,15 @@ export class WidgetStore {
1836
1973
  return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1837
1974
  }
1838
1975
 
1976
+ /**
1977
+ * Puts a picked file in storage while the visitor is still writing, so
1978
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
1979
+ * when that token expires.
1980
+ */
1981
+ uploadAttachment(file, signal) {
1982
+ return this.client.uploadAttachment(file, signal);
1983
+ }
1984
+
1839
1985
  /**
1840
1986
  * Re-sends a text bubble that failed. It moves to the end of the thread,
1841
1987
  * since it now goes out after anything sent meanwhile. Rejects like
@@ -1897,23 +2043,30 @@ export class WidgetStore {
1897
2043
  }
1898
2044
  let createdId = null;
1899
2045
  try {
1900
- // Local files go up first; the message names each by its upload token.
2046
+ // Local files go up first, unless the composer already put them up; the
2047
+ // message names each by its upload token.
1901
2048
  const sentAttachments = await Promise.all(attachments.map(async ({
1902
2049
  name,
1903
2050
  url,
1904
2051
  fileUri,
2052
+ uploadToken,
1905
2053
  contentType,
1906
2054
  size
1907
- }) => fileUri ? {
2055
+ }) => uploadToken ? {
2056
+ name,
2057
+ contentType,
2058
+ size,
2059
+ uploadToken
2060
+ } : fileUri ? {
1908
2061
  name,
1909
2062
  contentType,
1910
2063
  size,
1911
- uploadToken: await this.client.uploadAttachment({
2064
+ uploadToken: (await this.client.uploadAttachment({
1912
2065
  uri: fileUri,
1913
2066
  name,
1914
2067
  contentType,
1915
2068
  size
1916
- })
2069
+ })).uploadToken
1917
2070
  } : {
1918
2071
  name,
1919
2072
  url,
@@ -1935,6 +2088,11 @@ export class WidgetStore {
1935
2088
  });
1936
2089
  if (this.isStale(epoch)) return;
1937
2090
  if (ownCreation) createdId = result.conversationId;
2091
+
2092
+ // Workstation doesn't stream the visitor's own messages back, so the
2093
+ // list row learns about this one here.
2094
+ const sentAt = new Date().toISOString();
2095
+ const sentRow = rowFieldsFor(trimmed, attachments.map(attachment => attachment.contentType), true);
1938
2096
  this.setState(current => {
1939
2097
  // The stream or a catch-up fetch may have delivered this message
1940
2098
  // already — keep one copy, never two bubbles with the same id.
@@ -1944,18 +2102,27 @@ export class WidgetStore {
1944
2102
  id: result.messageId,
1945
2103
  status: this.earlyStatuses.get(result.messageId) ?? 'sent'
1946
2104
  } : message);
1947
- if (!createdId) return {
1948
- messages
1949
- };
2105
+ if (!createdId) {
2106
+ return {
2107
+ messages,
2108
+ conversations: current.conversations.map(row => row.id === result.conversationId ? {
2109
+ ...row,
2110
+ ...sentRow,
2111
+ lastMessageAt: sentAt,
2112
+ messageCount: row.messageCount + (delivered ? 0 : 1)
2113
+ } : row)
2114
+ };
2115
+ }
1950
2116
  const known = current.conversations.some(item => item.id === result.conversationId);
1951
2117
  const conversation = {
1952
2118
  id: result.conversationId,
1953
2119
  subject: null,
1954
2120
  status: 'open',
1955
- snippet: trimmed ? trimmed.slice(0, 100) : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2121
+ snippet: null,
2122
+ ...sentRow,
1956
2123
  messageCount: 1,
1957
- lastMessageAt: new Date().toISOString(),
1958
- createdAt: new Date().toISOString(),
2124
+ lastMessageAt: sentAt,
2125
+ createdAt: sentAt,
1959
2126
  agentName: null,
1960
2127
  agentAvatar: null
1961
2128
  };
@@ -1968,6 +2135,9 @@ export class WidgetStore {
1968
2135
  } : {})
1969
2136
  };
1970
2137
  });
2138
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', {
2139
+ messageId: result.messageId
2140
+ });
1971
2141
 
1972
2142
  // New files to fetch the stored copy of: a fresh round of re-reads.
1973
2143
  const sent = this.state.messages.find(message => message.id === result.messageId);
@@ -2490,6 +2660,65 @@ function asDeletedPlaceholder(message, deletedAt) {
2490
2660
  };
2491
2661
  }
2492
2662
 
2663
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2664
+ function plainTextOf(message) {
2665
+ return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
2666
+ }
2667
+
2668
+ /**
2669
+ * The newest message of a thread as this device saw it. `at` is its
2670
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2671
+ * message it's the phone's clock instead.
2672
+ */
2673
+
2674
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2675
+
2676
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2677
+ function rowFieldsOf(message) {
2678
+ if (message.messageType && message.messageType !== 'message') return null;
2679
+ return rowFieldsFor(plainTextOf(message), (message.attachments ?? []).map(attachment => attachment.contentType), message.senderType === 'visitor' || message.direction === 'inbound', /<img\b/i.test(message.body ?? ''));
2680
+ }
2681
+
2682
+ /**
2683
+ * Its words, or with none, whether it carried only photos or other files too.
2684
+ * A message with neither (a card) still says who sent it, so a teammate's
2685
+ * card never leaves "You:" on the visitor's earlier message.
2686
+ */
2687
+ function rowFieldsFor(text, contentTypes, fromVisitor, inlinePhoto = false) {
2688
+ if (text) return {
2689
+ snippet: text,
2690
+ lastMessageAttachment: null,
2691
+ lastMessageFromVisitor: fromVisitor
2692
+ };
2693
+ const count = contentTypes.length;
2694
+ if (count === 0 && !inlinePhoto) return {
2695
+ lastMessageAttachment: null,
2696
+ lastMessageFromVisitor: fromVisitor
2697
+ };
2698
+ return {
2699
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2700
+ ...(count > 0 ? {
2701
+ snippet: `${count} attachment${count === 1 ? '' : 's'}`
2702
+ } : {}),
2703
+ lastMessageAttachment: contentTypes.every(type => isImageAttachment(type)) ? 'photo' : 'file',
2704
+ lastMessageFromVisitor: fromVisitor
2705
+ };
2706
+ }
2707
+
2708
+ /**
2709
+ * The list stamps a conversation just after storing its message, so its time
2710
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2711
+ * A sent message is timed by the phone instead: a little drift either way
2712
+ * still matches, but only when the list rules out a team reply the visitor
2713
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2714
+ * there a sent message must match like any other.
2715
+ */
2716
+ function describesSameMessage(conversation, known) {
2717
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2718
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2719
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2720
+ }
2721
+
2493
2722
  /** A deletion missed while the stream was down shows up on the next re-read. */
2494
2723
  function adoptServerDeletions(messages, server) {
2495
2724
  const deleted = new Map(server.filter(message => message.deletedAt).map(message => [message.id, message.deletedAt]));
@@ -2537,13 +2766,31 @@ function mergeAttachments(local, server) {
2537
2766
  if (local.length !== server.length) {
2538
2767
  return server.every(attachment => attachment.url) ? server : local;
2539
2768
  }
2769
+ const owns = pairByName(local, server);
2540
2770
  const merged = server.map((attachment, index) => {
2541
- const own = local[index];
2542
- return isLocalAttachment(own) && attachment.url ? attachment : own;
2771
+ const own = owns[index];
2772
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2773
+ rememberLocalPreview(attachment.url, own.url);
2774
+ return attachment;
2543
2775
  });
2544
2776
  return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2545
2777
  }
2546
2778
 
2779
+ /**
2780
+ * The local attachment each server one replaces. By name first: Workstation
2781
+ * may store a message's files in another order than they were sent, and a
2782
+ * photo paired with the wrong file would show it for the rest of the session.
2783
+ * Any it renamed pair up in the order left.
2784
+ */
2785
+ function pairByName(local, server) {
2786
+ const unpaired = [...local];
2787
+ const named = server.map(attachment => {
2788
+ const index = unpaired.findIndex(own => own.name === attachment.name);
2789
+ return index < 0 ? null : unpaired.splice(index, 1)[0];
2790
+ });
2791
+ return named.map(own => own ?? unpaired.shift());
2792
+ }
2793
+
2547
2794
  /**
2548
2795
  * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2549
2796
  * rows. Such a row replaces the pending or failed bubble it came from instead