@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
@@ -10,13 +10,22 @@
10
10
 
11
11
  import { AppState, type AppStateStatus, Platform } from 'react-native';
12
12
 
13
- import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient } from '../api/client';
13
+ import { isTerminalErrorCode, RepzoWidgetError, WidgetApiClient, type UploadedFile } from '../api/client';
14
14
  import { WidgetRealtime } from '../api/realtime';
15
15
  import { getDeviceId, rotateDeviceId } from '../device-id';
16
16
  import { EventEmitter, RepzoWidgetEvents } from '../events';
17
17
  import { PushManager } from '../push/push-notifications';
18
- import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from '../storage';
19
- import { setAttachmentOptions } from '../attachments/pickers';
18
+ import {
19
+ CONFIG_KEY,
20
+ LAST_MESSAGE_KEY,
21
+ LEGACY_CONFIG_KEY,
22
+ SecureStorage,
23
+ Storage,
24
+ UNREAD_KEY,
25
+ USER_KEY,
26
+ } from '../storage';
27
+ import { clearLocalPreviews, rememberLocalPreview } from '../attachments/local-previews';
28
+ import { guessContentType, isImageAttachment, setAttachmentOptions } from '../attachments/pickers';
20
29
  import { DEFAULT_BASE_URL } from '../types';
21
30
  import type {
22
31
  AgentUiAction,
@@ -37,11 +46,13 @@ import type {
37
46
  WidgetUser,
38
47
  WidgetView,
39
48
  } from '../types';
49
+ import { clearRetainedState } from '../ui/hooks/use-retained-state';
40
50
  import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
51
+ import { stripHtml } from '../ui/utils/html';
41
52
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
42
53
 
43
54
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
44
- const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
55
+ const SDK_VERSION = '@repzo/widget-react-native@1.4.0';
45
56
 
46
57
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
47
58
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -62,6 +73,12 @@ const TYPING_SETTLE_MS = 3_000;
62
73
  const TYPING_PING_INTERVAL_MS = 4_000;
63
74
  /** Guards against the SSE stream replaying messages after a reconnect. */
64
75
  const SEEN_MESSAGE_LIMIT = 100;
76
+ /** Threads whose newest message is remembered across launches. */
77
+ const LAST_MESSAGE_LIMIT = 50;
78
+ /** How far the phone's clock may be from Workstation's for a sent message to still match the list. */
79
+ const SAME_MESSAGE_DRIFT_MS = 120_000;
80
+ /** How far the list's `lastMessageAt` may trail the message's own `createdAt` (7 ms seen live). */
81
+ const LIST_LAG_MS = 1_000;
65
82
  /** Logout's server calls are best-effort — a dead network must not block sign-out. */
66
83
  const LOGOUT_REQUEST_TIMEOUT_MS = 5_000;
67
84
  /** A server that 401s every fresh stream must not spin init (10/min limit). */
@@ -174,6 +191,10 @@ export class WidgetStore {
174
191
 
175
192
  /** `boot({ posts })` — Send posts over the app. */
176
193
  private postsEnabled = true;
194
+ /** `boot({ photoAlbums })` — a message's photos share one album in the thread. */
195
+ private photoAlbumsEnabled = true;
196
+ /** `boot({ youPrefix })` — "You:" before the visitor's own newest message in the lists. */
197
+ private youPrefixEnabled = true;
177
198
 
178
199
  /** Posts whose view was reported this session (the server also dedupes per day). */
179
200
  private viewedPostIds = new Set<string>();
@@ -236,6 +257,11 @@ export class WidgetStore {
236
257
  private visitorTypingActive = false;
237
258
 
238
259
  private seenMessageIds: string[] = [];
260
+
261
+ /** Who sent each thread's newest message and what it carried; see `withLastMessage`. */
262
+ private lastMessages: Record<string, LastMessageHint> = {};
263
+
264
+ private lastMessagesLoaded = false;
239
265
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
240
266
  private earlyStatuses = new Map<string, string>();
241
267
  /** The agent UI action behind a failed bubble, so Retry runs it again too. */
@@ -321,6 +347,10 @@ export class WidgetStore {
321
347
  /** Composer identity: explicit opens reset it; a draft receiving its server ID does not. */
322
348
  getThreadGeneration = (): number => this.threadGeneration;
323
349
 
350
+ showsPhotoAlbums = (): boolean => this.photoAlbumsEnabled;
351
+
352
+ showsYouPrefix = (): boolean => this.youPrefixEnabled;
353
+
324
354
  private setState(patch: Partial<WidgetState> | ((current: WidgetState) => Partial<WidgetState>)) {
325
355
  const previous = this.state;
326
356
  const resolved = typeof patch === 'function' ? patch(previous) : patch;
@@ -394,6 +424,8 @@ export class WidgetStore {
394
424
  if (options.inAppNotifications !== undefined) {
395
425
  this.inAppNotificationsEnabled = options.inAppNotifications !== false;
396
426
  }
427
+ if (options.photoAlbums !== undefined) this.photoAlbumsEnabled = options.photoAlbums !== false;
428
+ if (options.youPrefix !== undefined) this.youPrefixEnabled = options.youPrefix !== false;
397
429
  if (options.posts !== undefined) {
398
430
  const wasEnabled = this.postsEnabled;
399
431
  this.postsEnabled = options.posts !== false;
@@ -555,15 +587,15 @@ export class WidgetStore {
555
587
  ]);
556
588
  if (this.isStale(epoch)) return;
557
589
 
558
- const conversations = result.conversations.map((conversation) =>
559
- this.hydrateConversation(conversation)
560
- );
561
-
562
590
  // Server counts win (they include replies pushed while the app was
563
591
  // killed); the locally persisted map only covers older Workstation
564
592
  // deployments that don't send unreadCount yet.
565
- const storedUnread = await this.loadUnread();
593
+ const [, storedUnread] = await Promise.all([this.loadLastMessages(epoch), this.loadUnread()]);
566
594
  if (this.isStale(epoch)) return;
595
+ const conversations = result.conversations.map((conversation) =>
596
+ this.hydrateConversation(conversation)
597
+ );
598
+
567
599
  const unreadByConversation: Record<string, number> = {};
568
600
  for (const conversation of conversations) {
569
601
  const count =
@@ -851,11 +883,15 @@ export class WidgetStore {
851
883
  this.userKeyLoaded = true;
852
884
  this.pendingAgentByConversation = {};
853
885
  this.seenMessageIds = [];
886
+ this.lastMessages = {};
887
+ this.lastMessagesLoaded = false;
854
888
  this.earlyStatuses.clear();
855
889
  this.visitorTypingActive = false;
856
890
  this.lastTypingPingAt = 0;
857
891
  this.launchCardOffered = false;
858
892
  this.resetPost();
893
+ clearLocalPreviews();
894
+ clearRetainedState();
859
895
  this.setState({ ...USER_SCOPED_STATE });
860
896
 
861
897
  const token = await this.client.getToken();
@@ -863,6 +899,7 @@ export class WidgetStore {
863
899
  this.client.clearToken(),
864
900
  SecureStorage.removeItem(USER_KEY),
865
901
  Storage.removeItem(UNREAD_KEY),
902
+ Storage.removeItem(LAST_MESSAGE_KEY),
866
903
  // A new anonymous visitor on this device gets a new tracking identity.
867
904
  rotateDeviceId(),
868
905
  ]);
@@ -1284,6 +1321,7 @@ export class WidgetStore {
1284
1321
 
1285
1322
  // Set inside the updater, read after: `setState` runs synchronously.
1286
1323
  let countedAsUnread = false;
1324
+ const rowFields = rowFieldsOf(message);
1287
1325
 
1288
1326
  this.setState((current) => {
1289
1327
  const conversations = conversationId
@@ -1291,10 +1329,7 @@ export class WidgetStore {
1291
1329
  conversation.id === conversationId
1292
1330
  ? {
1293
1331
  ...conversation,
1294
- snippet:
1295
- message.messageType === 'message'
1296
- ? message.bodyPlain || conversation.snippet
1297
- : conversation.snippet,
1332
+ ...rowFields,
1298
1333
  lastMessageAt: message.createdAt,
1299
1334
  messageCount: conversation.messageCount + 1,
1300
1335
  }
@@ -1336,12 +1371,14 @@ export class WidgetStore {
1336
1371
  messageId,
1337
1372
  senderName: message.senderName || conversation?.agentName || null,
1338
1373
  senderAvatar: conversation?.agentAvatar ?? null,
1339
- preview: (message.bodyPlain ?? '').trim(),
1374
+ preview: plainTextOf(message),
1340
1375
  hasAttachments: (message.attachments?.length ?? 0) > 0,
1341
1376
  },
1342
1377
  };
1343
1378
  });
1344
1379
 
1380
+ if (conversationId && rowFields) this.rememberLastMessage(conversationId, message.createdAt, rowFields, 'stream');
1381
+
1345
1382
  // The visitor is reading it live — move the server cursor too, or the
1346
1383
  // next list/init would badge a reply they already saw.
1347
1384
  if ((onScreen || readInPost) && isTeamReply && conversationId) this.scheduleMarkRead(conversationId);
@@ -1438,10 +1475,116 @@ export class WidgetStore {
1438
1475
  }
1439
1476
 
1440
1477
  private hydrateConversation(conversation: Conversation): Conversation {
1441
- return this.applyConversationAgent({
1478
+ return this.applyConversationAgent(
1479
+ this.withLastMessage({
1480
+ ...conversation,
1481
+ agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1482
+ })
1483
+ );
1484
+ }
1485
+
1486
+ /**
1487
+ * Workstation's list doesn't say who sent the newest message, and calls one
1488
+ * without words "2 attachments". While a row still describes the message
1489
+ * this device saw last, both are filled in from it. `snippet` keeps the
1490
+ * list's words: apps read it, and the lists draw "Photo" or "File" from
1491
+ * `lastMessageAttachment` instead.
1492
+ */
1493
+ private withLastMessage(conversation: Conversation): Conversation {
1494
+ const known = this.lastMessages[conversation.id];
1495
+ if (!known || !describesSameMessage(conversation, known)) return conversation;
1496
+ // A list that says who sent it, or what it carried, is used as is.
1497
+ return {
1442
1498
  ...conversation,
1443
- agentAvatar: this.client.resolveUrl(conversation.agentAvatar),
1499
+ lastMessageFromVisitor: conversation.lastMessageFromVisitor ?? known.fromVisitor,
1500
+ lastMessageAttachment:
1501
+ conversation.lastMessageAttachment !== undefined ? conversation.lastMessageAttachment : known.attachment,
1502
+ };
1503
+ }
1504
+
1505
+ /**
1506
+ * A send or a streamed message is the newest by arrival, whatever the two
1507
+ * clocks say; a history page only replaces something older. A sent message
1508
+ * is timed by the phone, which may run ahead of Workstation, so instead a
1509
+ * page that has it replaces it: whatever is newest there came after it, and
1510
+ * Workstation's own time then stands in for the phone's.
1511
+ */
1512
+ private rememberLastMessage(
1513
+ conversationId: string,
1514
+ at: string,
1515
+ fields: LastMessageFields,
1516
+ source: 'sent' | 'stream' | 'history',
1517
+ { messageId, pageIds }: { messageId?: string; pageIds?: ReadonlySet<string> } = {}
1518
+ ): void {
1519
+ const known = this.lastMessages[conversationId];
1520
+ if (source === 'history' && known) {
1521
+ const outdated =
1522
+ known.phoneClock && known.messageId
1523
+ ? !pageIds?.has(known.messageId)
1524
+ : Date.parse(known.at) > Date.parse(at);
1525
+ if (outdated) return;
1526
+ }
1527
+ const hint: LastMessageHint = {
1528
+ at,
1529
+ fromVisitor: !!fields.lastMessageFromVisitor,
1530
+ attachment: fields.lastMessageAttachment ?? null,
1531
+ ...(source === 'sent' ? { phoneClock: true, ...(messageId ? { messageId } : {}) } : {}),
1532
+ };
1533
+ this.lastMessages = Object.fromEntries(
1534
+ Object.entries({ ...this.lastMessages, [conversationId]: hint })
1535
+ .sort(([, a], [, b]) => Date.parse(b.at) - Date.parse(a.at))
1536
+ .slice(0, LAST_MESSAGE_LIMIT)
1537
+ );
1538
+ // Until the stored hints are read, saving would overwrite them with these alone.
1539
+ if (this.lastMessagesLoaded) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1540
+ }
1541
+
1542
+ private async loadLastMessages(epoch: number): Promise<void> {
1543
+ if (this.lastMessagesLoaded) return;
1544
+ let stored: unknown = null;
1545
+ try {
1546
+ stored = JSON.parse((await Storage.getItem(LAST_MESSAGE_KEY)) ?? 'null');
1547
+ } catch {
1548
+ stored = null;
1549
+ }
1550
+ // Left unloaded: the init that replaces this one reads them again.
1551
+ if (this.isStale(epoch) || this.lastMessagesLoaded) return;
1552
+ this.lastMessagesLoaded = true;
1553
+ const learned = Object.keys(this.lastMessages).length > 0;
1554
+ if (stored && typeof stored === 'object') {
1555
+ for (const [conversationId, hint] of Object.entries(stored as Record<string, Partial<LastMessageHint>>)) {
1556
+ if (typeof hint?.at !== 'string' || typeof hint.fromVisitor !== 'boolean') continue;
1557
+ const known = this.lastMessages[conversationId];
1558
+ if (known && Date.parse(known.at) >= Date.parse(hint.at)) continue;
1559
+ this.lastMessages[conversationId] = {
1560
+ at: hint.at,
1561
+ fromVisitor: hint.fromVisitor,
1562
+ attachment: hint.attachment === 'photo' || hint.attachment === 'file' ? hint.attachment : null,
1563
+ ...(hint.phoneClock === true ? { phoneClock: true } : {}),
1564
+ ...(typeof hint.messageId === 'string' ? { messageId: hint.messageId } : {}),
1565
+ };
1566
+ }
1567
+ }
1568
+ // Hints learned while the stored ones were being read are saved with them.
1569
+ if (learned) void Storage.setItem(LAST_MESSAGE_KEY, JSON.stringify(this.lastMessages));
1570
+ }
1571
+
1572
+ /** A thread's newest page shows who sent its newest message, so its row can say too. */
1573
+ private learnLastMessage(conversationId: string, page: Message[]): void {
1574
+ let newest: Message | null = null;
1575
+ for (const message of page) {
1576
+ if (!newest || Date.parse(message.createdAt) > Date.parse(newest.createdAt)) newest = message;
1577
+ }
1578
+ const fields = newest && rowFieldsOf(newest);
1579
+ if (!newest || !fields) return;
1580
+ this.rememberLastMessage(conversationId, newest.createdAt, fields, 'history', {
1581
+ pageIds: new Set(page.map((message) => message.id)),
1444
1582
  });
1583
+ this.setState((current) => ({
1584
+ conversations: current.conversations.map((row) =>
1585
+ row.id === conversationId ? this.withLastMessage(row) : row
1586
+ ),
1587
+ }));
1445
1588
  }
1446
1589
 
1447
1590
  /**
@@ -1452,21 +1595,30 @@ export class WidgetStore {
1452
1595
  if (!Array.isArray(attachments) || attachments.length === 0) return null;
1453
1596
  const hydrated = attachments
1454
1597
  .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
1455
- .map((attachment, index) => ({
1456
- ...attachment,
1457
- id:
1458
- typeof attachment.id === 'string' || typeof attachment.id === 'number'
1459
- ? String(attachment.id)
1460
- : `${messageId ?? 'attachment'}-${index}`,
1461
- name:
1462
- typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment',
1463
- contentType: typeof attachment.contentType === 'string' ? attachment.contentType : '',
1464
- size: typeof attachment.size === 'number' ? attachment.size : 0,
1465
- // Empty (never the raw value) when the URL isn't http(s) — the preview
1466
- // then renders the name only, with nothing to open.
1467
- url:
1468
- this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1469
- }));
1598
+ .map((attachment, index) => {
1599
+ const name =
1600
+ typeof attachment.name === 'string' && attachment.name ? attachment.name : 'Attachment';
1601
+ const declared = typeof attachment.contentType === 'string' ? attachment.contentType : '';
1602
+ return {
1603
+ ...attachment,
1604
+ id:
1605
+ typeof attachment.id === 'string' || typeof attachment.id === 'number'
1606
+ ? String(attachment.id)
1607
+ : `${messageId ?? 'attachment'}-${index}`,
1608
+ name,
1609
+ // Without a type (or with a generic one) the name says what it is,
1610
+ // so a photo still shows as a photo rather than a file row.
1611
+ contentType:
1612
+ declared && !/^application\/octet-stream$/i.test(declared)
1613
+ ? declared
1614
+ : (guessContentType(name) ?? declared),
1615
+ size: typeof attachment.size === 'number' ? attachment.size : 0,
1616
+ // Empty (never the raw value) when the URL isn't http(s) — the preview
1617
+ // then renders the name only, with nothing to open.
1618
+ url:
1619
+ this.client.resolveUrl(typeof attachment.url === 'string' ? attachment.url : null) ?? '',
1620
+ };
1621
+ });
1470
1622
  return hydrated.length > 0 ? hydrated : null;
1471
1623
  }
1472
1624
 
@@ -1707,10 +1859,10 @@ export class WidgetStore {
1707
1859
  const result = await this.client.listMessages(conversationId);
1708
1860
  // Opening A then B quickly: A's page must never land under B.
1709
1861
  if (this.isStale(epoch) || this.threadGeneration !== generation) return;
1862
+ const page = this.hydrateMessages(result.data);
1710
1863
  this.setState((current) => {
1711
1864
  // Live replies and optimistic sends may have arrived since this page
1712
1865
  // was requested. Keep them, preferring the live copy for shared ids.
1713
- const page = this.hydrateMessages(result.data);
1714
1866
  const live = adoptOwnServerRows(current.messages, page);
1715
1867
  const liveIds = new Set(live.map((message) => message.id));
1716
1868
  const history = page.filter((message) => !liveIds.has(message.id));
@@ -1721,6 +1873,7 @@ export class WidgetStore {
1721
1873
  messageCursor: result.meta.nextCursor,
1722
1874
  };
1723
1875
  });
1876
+ this.learnLastMessage(conversationId, page);
1724
1877
  } catch {
1725
1878
  if (!this.isStale(epoch) && this.threadGeneration === generation) {
1726
1879
  this.setState({ messagesLoading: false });
@@ -1778,6 +1931,7 @@ export class WidgetStore {
1778
1931
  if (this.state.activeConversationId !== conversationId) return;
1779
1932
 
1780
1933
  const fresh = this.hydrateMessages(result.data);
1934
+ this.learnLastMessage(conversationId, fresh);
1781
1935
  this.setState((current) => {
1782
1936
  const own = adoptOwnServerRows(current.messages, fresh);
1783
1937
  const known = new Set(own.map((message) => message.id));
@@ -1956,6 +2110,18 @@ export class WidgetStore {
1956
2110
  return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1957
2111
  }
1958
2112
 
2113
+ /**
2114
+ * Puts a picked file in storage while the visitor is still writing, so
2115
+ * Send goes out at once. Resolves the token `sendMessage` names it by, and
2116
+ * when that token expires.
2117
+ */
2118
+ uploadAttachment(
2119
+ file: { uri: string; name: string; contentType: string; size: number },
2120
+ signal?: AbortSignal
2121
+ ): Promise<UploadedFile> {
2122
+ return this.client.uploadAttachment(file, signal);
2123
+ }
2124
+
1959
2125
  /**
1960
2126
  * Re-sends a text bubble that failed. It moves to the end of the thread,
1961
2127
  * since it now goes out after anything sent meanwhile. Rejects like
@@ -2023,17 +2189,21 @@ export class WidgetStore {
2023
2189
 
2024
2190
  let createdId: string | null = null;
2025
2191
  try {
2026
- // Local files go up first; the message names each by its upload token.
2192
+ // Local files go up first, unless the composer already put them up; the
2193
+ // message names each by its upload token.
2027
2194
  const sentAttachments = await Promise.all(
2028
- attachments.map(async ({ name, url, fileUri, contentType, size }) =>
2029
- fileUri
2030
- ? {
2031
- name,
2032
- contentType,
2033
- size,
2034
- uploadToken: await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }),
2035
- }
2036
- : { name, url, contentType, size }
2195
+ attachments.map(async ({ name, url, fileUri, uploadToken, contentType, size }) =>
2196
+ uploadToken
2197
+ ? { name, contentType, size, uploadToken }
2198
+ : fileUri
2199
+ ? {
2200
+ name,
2201
+ contentType,
2202
+ size,
2203
+ uploadToken: (await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }))
2204
+ .uploadToken,
2205
+ }
2206
+ : { name, url, contentType, size }
2037
2207
  )
2038
2208
  );
2039
2209
  if (this.isStale(epoch)) return;
@@ -2052,6 +2222,10 @@ export class WidgetStore {
2052
2222
  if (this.isStale(epoch)) return;
2053
2223
  if (ownCreation) createdId = result.conversationId;
2054
2224
 
2225
+ // Workstation doesn't stream the visitor's own messages back, so the
2226
+ // list row learns about this one here.
2227
+ const sentAt = new Date().toISOString();
2228
+ const sentRow = rowFieldsFor(trimmed, attachments.map((attachment) => attachment.contentType), true);
2055
2229
  this.setState((current) => {
2056
2230
  // The stream or a catch-up fetch may have delivered this message
2057
2231
  // already — keep one copy, never two bubbles with the same id.
@@ -2064,19 +2238,27 @@ export class WidgetStore {
2064
2238
  : message
2065
2239
  );
2066
2240
 
2067
- if (!createdId) return { messages };
2241
+ if (!createdId) {
2242
+ return {
2243
+ messages,
2244
+ conversations: current.conversations.map((row) =>
2245
+ row.id === result.conversationId
2246
+ ? { ...row, ...sentRow, lastMessageAt: sentAt, messageCount: row.messageCount + (delivered ? 0 : 1) }
2247
+ : row
2248
+ ),
2249
+ };
2250
+ }
2068
2251
 
2069
2252
  const known = current.conversations.some((item) => item.id === result.conversationId);
2070
2253
  const conversation: Conversation = {
2071
2254
  id: result.conversationId,
2072
2255
  subject: null,
2073
2256
  status: 'open',
2074
- snippet: trimmed
2075
- ? trimmed.slice(0, 100)
2076
- : `${attachments.length} attachment${attachments.length === 1 ? '' : 's'}`,
2257
+ snippet: null,
2258
+ ...sentRow,
2077
2259
  messageCount: 1,
2078
- lastMessageAt: new Date().toISOString(),
2079
- createdAt: new Date().toISOString(),
2260
+ lastMessageAt: sentAt,
2261
+ createdAt: sentAt,
2080
2262
  agentName: null,
2081
2263
  agentAvatar: null,
2082
2264
  };
@@ -2092,6 +2274,7 @@ export class WidgetStore {
2092
2274
  : {}),
2093
2275
  };
2094
2276
  });
2277
+ this.rememberLastMessage(result.conversationId, sentAt, sentRow, 'sent', { messageId: result.messageId });
2095
2278
 
2096
2279
  // New files to fetch the stored copy of: a fresh round of re-reads.
2097
2280
  const sent = this.state.messages.find((message) => message.id === result.messageId);
@@ -2593,6 +2776,77 @@ function asDeletedPlaceholder(message: Message, deletedAt: string): Message {
2593
2776
  return { ...message, body: null, bodyPlain: null, attachments: null, metadata: null, deletedAt };
2594
2777
  }
2595
2778
 
2779
+ /** A message's words on one line; replies written in Workstation may only carry HTML. */
2780
+ function plainTextOf(message: Message): string {
2781
+ return (message.bodyPlain || stripHtml(message.body ?? '')).replace(/\s+/g, ' ').trim();
2782
+ }
2783
+
2784
+ /**
2785
+ * The newest message of a thread as this device saw it. `at` is its
2786
+ * `createdAt`, which Workstation's list repeats as `lastMessageAt`; for a sent
2787
+ * message it's the phone's clock instead.
2788
+ */
2789
+ interface LastMessageHint {
2790
+ at: string;
2791
+ fromVisitor: boolean;
2792
+ attachment: 'photo' | 'file' | null;
2793
+ phoneClock?: boolean;
2794
+ /** A sent message's id: a page from Workstation that has it can retime it. */
2795
+ messageId?: string;
2796
+ }
2797
+
2798
+ /** `snippet` is absent when there's nothing to quote: the row keeps its words. */
2799
+ type LastMessageFields = Pick<Conversation, 'lastMessageAttachment' | 'lastMessageFromVisitor'> & {
2800
+ snippet?: string;
2801
+ };
2802
+
2803
+ /** The list row once `message` is the newest. Null keeps the row as it is: system rows and notes. */
2804
+ function rowFieldsOf(message: Message): LastMessageFields | null {
2805
+ if (message.messageType && message.messageType !== 'message') return null;
2806
+ return rowFieldsFor(
2807
+ plainTextOf(message),
2808
+ (message.attachments ?? []).map((attachment) => attachment.contentType),
2809
+ message.senderType === 'visitor' || message.direction === 'inbound',
2810
+ /<img\b/i.test(message.body ?? '')
2811
+ );
2812
+ }
2813
+
2814
+ /**
2815
+ * Its words, or with none, whether it carried only photos or other files too.
2816
+ * A message with neither (a card) still says who sent it, so a teammate's
2817
+ * card never leaves "You:" on the visitor's earlier message.
2818
+ */
2819
+ function rowFieldsFor(
2820
+ text: string,
2821
+ contentTypes: Array<string | null | undefined>,
2822
+ fromVisitor: boolean,
2823
+ inlinePhoto = false
2824
+ ): LastMessageFields {
2825
+ if (text) return { snippet: text, lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2826
+ const count = contentTypes.length;
2827
+ if (count === 0 && !inlinePhoto) return { lastMessageAttachment: null, lastMessageFromVisitor: fromVisitor };
2828
+ return {
2829
+ // Workstation's list words it this way too, so apps reading `snippet` see the same after a reload.
2830
+ ...(count > 0 ? { snippet: `${count} attachment${count === 1 ? '' : 's'}` } : {}),
2831
+ lastMessageAttachment: contentTypes.every((type) => isImageAttachment(type)) ? 'photo' : 'file',
2832
+ lastMessageFromVisitor: fromVisitor,
2833
+ };
2834
+ }
2835
+
2836
+ /**
2837
+ * The list stamps a conversation just after storing its message, so its time
2838
+ * trails the message's own by a few milliseconds, never more than `LIST_LAG_MS`.
2839
+ * A sent message is timed by the phone instead: a little drift either way
2840
+ * still matches, but only when the list rules out a team reply the visitor
2841
+ * hasn't read. Older Workstation deployments don't send `unreadCount`, so
2842
+ * there a sent message must match like any other.
2843
+ */
2844
+ function describesSameMessage(conversation: Conversation, known: LastMessageHint): boolean {
2845
+ const lag = Date.parse(conversation.lastMessageAt ?? '') - Date.parse(known.at);
2846
+ if (lag >= 0 && lag <= LIST_LAG_MS) return true;
2847
+ return !!known.phoneClock && Math.abs(lag) <= SAME_MESSAGE_DRIFT_MS && conversation.unreadCount === 0;
2848
+ }
2849
+
2596
2850
  /** A deletion missed while the stream was down shows up on the next re-read. */
2597
2851
  function adoptServerDeletions(messages: Message[], server: Message[]): Message[] {
2598
2852
  const deleted = new Map(
@@ -2641,13 +2895,31 @@ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[])
2641
2895
  if (local.length !== server.length) {
2642
2896
  return server.every((attachment) => attachment.url) ? server : local;
2643
2897
  }
2898
+ const owns = pairByName(local, server);
2644
2899
  const merged = server.map((attachment, index) => {
2645
- const own = local[index]!;
2646
- return isLocalAttachment(own) && attachment.url ? attachment : own;
2900
+ const own = owns[index]!;
2901
+ if (!isLocalAttachment(own) || !attachment.url) return own;
2902
+ rememberLocalPreview(attachment.url, own.url);
2903
+ return attachment;
2647
2904
  });
2648
2905
  return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2649
2906
  }
2650
2907
 
2908
+ /**
2909
+ * The local attachment each server one replaces. By name first: Workstation
2910
+ * may store a message's files in another order than they were sent, and a
2911
+ * photo paired with the wrong file would show it for the rest of the session.
2912
+ * Any it renamed pair up in the order left.
2913
+ */
2914
+ function pairByName(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
2915
+ const unpaired = [...local];
2916
+ const named = server.map((attachment) => {
2917
+ const index = unpaired.findIndex((own) => own.name === attachment.name);
2918
+ return index < 0 ? null : unpaired.splice(index, 1)[0]!;
2919
+ });
2920
+ return named.map((own) => own ?? unpaired.shift()!);
2921
+ }
2922
+
2651
2923
  /**
2652
2924
  * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2653
2925
  * rows. Such a row replaces the pending or failed bubble it came from instead
package/src/storage.ts CHANGED
@@ -20,6 +20,8 @@ export const TOKEN_KEY = 'repzo_widget_token';
20
20
  export const USER_KEY = 'repzo_widget_user_key';
21
21
  export const DEVICE_ID_KEY = 'repzo_widget_device_id';
22
22
  export const UNREAD_KEY = 'repzo_widget_unread';
23
+ /** Who sent each conversation's newest message; Workstation's list doesn't say. */
24
+ export const LAST_MESSAGE_KEY = 'repzo_widget_last_message';
23
25
  /** Last config received — repaints the real theme instantly on relaunch. */
24
26
  /**
25
27
  * v2 wraps the config with the channel/baseUrl/platform it came from. v1 held a
package/src/types.ts CHANGED
@@ -104,6 +104,8 @@ export interface OutgoingAttachment {
104
104
  url: string;
105
105
  /** A local file uploaded before the message is sent (servers with `maxAttachmentBytes`). */
106
106
  fileUri?: string;
107
+ /** `fileUri` is already uploaded under this token, so the send skips uploading it. */
108
+ uploadToken?: string;
107
109
  contentType: string;
108
110
  /** Decoded bytes. */
109
111
  size: number;
@@ -116,6 +118,21 @@ export interface Conversation {
116
118
  subject: string | null;
117
119
  status: string;
118
120
  snippet: string | null;
121
+ /**
122
+ * Set when the newest message had no words, only photos (`'photo'`) or
123
+ * other files (`'file'`); the lists then say "Photo" or "File", while
124
+ * `snippet` keeps Workstation's wording ("2 attachments"). `null` when it
125
+ * had words. Filled in from what this device sent, received or loaded.
126
+ * @since 1.4.0
127
+ */
128
+ lastMessageAttachment?: 'photo' | 'file' | null;
129
+ /**
130
+ * The newest message is the visitor's own; the lists put "You:" before it.
131
+ * A Workstation list that includes it is used as is; otherwise the SDK fills
132
+ * it in from what this device sent, received or loaded.
133
+ * @since 1.4.0
134
+ */
135
+ lastMessageFromVisitor?: boolean | null;
119
136
  messageCount: number;
120
137
  lastMessageAt: string | null;
121
138
  createdAt: string;
@@ -267,6 +284,22 @@ export interface BootOptions {
267
284
  * @since 1.2.0
268
285
  */
269
286
  posts?: boolean;
287
+ /**
288
+ * Show two or more photos in one message together as an album: one square
289
+ * holding them all, with "+N" on the last tile past four (default true).
290
+ * False shows each photo on its own, full width, as before 1.4.0. Either
291
+ * way, tapping a photo opens the message's photos in the viewer.
292
+ * @since 1.4.0
293
+ */
294
+ photoAlbums?: boolean;
295
+ /**
296
+ * Put "You: " before a conversation's newest message on Home and in
297
+ * Messages when the visitor sent it (default true; Arabic "أنت: ", the
298
+ * `lastMessageYou` translation key). False shows the message alone, as
299
+ * before 1.4.0. `Conversation.lastMessageFromVisitor` is filled in either way.
300
+ * @since 1.4.0
301
+ */
302
+ youPrefix?: boolean;
270
303
  }
271
304
 
272
305
  /** An option in the composer's attach menu. */
@@ -303,6 +336,14 @@ export interface RepzoWidgetAttachmentOptions {
303
336
  pick?: (source: AttachmentSource) => Promise<RepzoPickedFile[] | null>;
304
337
  /** Menu options offered with `pick`. Default all three. Ignored without `pick`. */
305
338
  sources?: AttachmentSource[];
339
+ /**
340
+ * Default true: with a Workstation that takes uploads, a picked file starts
341
+ * uploading right away, while the visitor writes, so the message goes out
342
+ * the moment they tap Send. False uploads the files only when they tap
343
+ * Send, so nothing leaves the phone for a file they remove first.
344
+ * @since 1.4.0
345
+ */
346
+ uploadOnPick?: boolean;
306
347
  }
307
348
 
308
349
  // ─── Agent UI blocks ────────────────────────────────────────────────────────