@repzo/widget-react-native 1.2.2 → 1.3.1

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 (172) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +43 -33
  3. package/android/build.gradle +22 -0
  4. package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
  5. package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/RepzoVoicePlayer.swift +175 -0
  8. package/ios/RepzoWidgetAudio.podspec +26 -0
  9. package/ios/RepzoWidgetAudioModule.swift +65 -0
  10. package/lib/commonjs/api/client.js +40 -0
  11. package/lib/commonjs/api/client.js.map +1 -1
  12. package/lib/commonjs/attachments/pickers.js +80 -23
  13. package/lib/commonjs/attachments/pickers.js.map +1 -1
  14. package/lib/commonjs/audio/native-player.js +97 -0
  15. package/lib/commonjs/audio/native-player.js.map +1 -0
  16. package/lib/commonjs/audio/voice.js +34 -7
  17. package/lib/commonjs/audio/voice.js.map +1 -1
  18. package/lib/commonjs/i18n.js +2 -0
  19. package/lib/commonjs/i18n.js.map +1 -1
  20. package/lib/commonjs/locales.js +1 -0
  21. package/lib/commonjs/locales.js.map +1 -1
  22. package/lib/commonjs/state/widget-store.js +238 -40
  23. package/lib/commonjs/state/widget-store.js.map +1 -1
  24. package/lib/commonjs/types.js.map +1 -1
  25. package/lib/commonjs/ui/WidgetProvider.js +8 -6
  26. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  27. package/lib/commonjs/ui/components/ImageViewer.js +20 -37
  28. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  29. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  30. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  31. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  32. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  35. package/lib/commonjs/ui/components/icons.js +22 -16
  36. package/lib/commonjs/ui/components/icons.js.map +1 -1
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/styles/theme.js +1 -0
  42. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  43. package/lib/commonjs/ui/utils/conversations.js +17 -1
  44. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  45. package/lib/module/api/client.js +40 -0
  46. package/lib/module/api/client.js.map +1 -1
  47. package/lib/module/attachments/pickers.js +80 -23
  48. package/lib/module/attachments/pickers.js.map +1 -1
  49. package/lib/module/audio/native-player.js +93 -0
  50. package/lib/module/audio/native-player.js.map +1 -0
  51. package/lib/module/audio/voice.js +32 -6
  52. package/lib/module/audio/voice.js.map +1 -1
  53. package/lib/module/i18n.js +2 -0
  54. package/lib/module/i18n.js.map +1 -1
  55. package/lib/module/locales.js +1 -0
  56. package/lib/module/locales.js.map +1 -1
  57. package/lib/module/state/widget-store.js +238 -40
  58. package/lib/module/state/widget-store.js.map +1 -1
  59. package/lib/module/types.js.map +1 -1
  60. package/lib/module/ui/WidgetProvider.js +8 -6
  61. package/lib/module/ui/WidgetProvider.js.map +1 -1
  62. package/lib/module/ui/components/ImageViewer.js +21 -38
  63. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  64. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  65. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  66. package/lib/module/ui/components/VoiceNote.js +9 -10
  67. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  68. package/lib/module/ui/components/ZoomableImage.js +291 -0
  69. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  70. package/lib/module/ui/components/icons.js +21 -15
  71. package/lib/module/ui/components/icons.js.map +1 -1
  72. package/lib/module/ui/screens/chat/ChatView.js +44 -20
  73. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  74. package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
  75. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  76. package/lib/module/ui/styles/theme.js +1 -0
  77. package/lib/module/ui/styles/theme.js.map +1 -1
  78. package/lib/module/ui/utils/conversations.js +15 -1
  79. package/lib/module/ui/utils/conversations.js.map +1 -1
  80. package/lib/typescript/commonjs/api/client.d.ts +27 -1
  81. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
  83. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  85. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  87. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/i18n.d.ts +1 -0
  89. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/state/widget-store.d.ts +38 -3
  92. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/types.d.ts +17 -0
  94. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  97. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  99. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  101. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  103. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  105. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  107. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +14 -9
  109. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  111. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  113. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  115. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  116. package/lib/typescript/module/api/client.d.ts +27 -1
  117. package/lib/typescript/module/api/client.d.ts.map +1 -1
  118. package/lib/typescript/module/attachments/pickers.d.ts +22 -10
  119. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  120. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  121. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  122. package/lib/typescript/module/audio/voice.d.ts +17 -4
  123. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  124. package/lib/typescript/module/i18n.d.ts +1 -0
  125. package/lib/typescript/module/i18n.d.ts.map +1 -1
  126. package/lib/typescript/module/locales.d.ts.map +1 -1
  127. package/lib/typescript/module/state/widget-store.d.ts +38 -3
  128. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  129. package/lib/typescript/module/types.d.ts +17 -0
  130. package/lib/typescript/module/types.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  133. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  134. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  135. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  136. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  137. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  139. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  140. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  141. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  143. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +14 -9
  145. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  147. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  149. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  151. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  152. package/package.json +10 -3
  153. package/react-native.config.js +12 -0
  154. package/src/api/client.ts +66 -2
  155. package/src/attachments/pickers.ts +87 -25
  156. package/src/audio/native-player.ts +106 -0
  157. package/src/audio/voice.ts +30 -6
  158. package/src/i18n.ts +2 -0
  159. package/src/locales.ts +1 -0
  160. package/src/state/widget-store.ts +233 -33
  161. package/src/types.ts +17 -0
  162. package/src/ui/WidgetProvider.tsx +16 -7
  163. package/src/ui/components/ImageViewer.tsx +21 -36
  164. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  165. package/src/ui/components/VoiceNote.tsx +6 -14
  166. package/src/ui/components/ZoomableImage.tsx +250 -0
  167. package/src/ui/components/icons.tsx +10 -12
  168. package/src/ui/screens/chat/ChatView.styles.ts +17 -9
  169. package/src/ui/screens/chat/ChatView.tsx +49 -25
  170. package/src/ui/screens/chat/ChatView.types.ts +4 -0
  171. package/src/ui/styles/theme.ts +6 -0
  172. package/src/ui/utils/conversations.ts +16 -2
@@ -13,6 +13,7 @@ var _pushNotifications = require("../push/push-notifications.js");
13
13
  var _storage = require("../storage.js");
14
14
  var _pickers = require("../attachments/pickers.js");
15
15
  var _types = require("../types.js");
16
+ var _conversations = require("../ui/utils/conversations.js");
16
17
  var _safeUrl = require("../ui/utils/safe-url.js");
17
18
  /**
18
19
  * The widget's single source of truth.
@@ -25,7 +26,7 @@ var _safeUrl = require("../ui/utils/safe-url.js");
25
26
  */
26
27
 
27
28
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
28
- const SDK_VERSION = '@repzo/widget-react-native@1.2.2';
29
+ const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
29
30
 
30
31
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
31
32
  function sessionPlatform() {
@@ -55,6 +56,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
55
56
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
56
57
  /** Live events held back while a draft's first send is creating its thread. */
57
58
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
59
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
60
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
58
61
  /** How long a post stays up after a reaction, thanking them, before it goes. */
59
62
  const POST_THANKS_MS = exports.POST_THANKS_MS = 1600;
60
63
 
@@ -171,6 +174,13 @@ class WidgetStore {
171
174
  /** True once `currentUserKey` mirrors storage — memory is then authoritative. */
172
175
  userKeyLoaded = false;
173
176
  pendingConversationId = null;
177
+
178
+ /**
179
+ * The screen a thread was opened from, so its back button returns there.
180
+ * Null when it was opened with the messenger closed (a push, a notification
181
+ * card, `presentConversation`): there is nothing to go back to.
182
+ */
183
+ chatOpenedFrom = null;
174
184
  appStateSubscription = null;
175
185
 
176
186
  /** When the app last went to the background (0: hasn't this session). */
@@ -180,6 +190,12 @@ class WidgetStore {
180
190
  seenMessageIds = [];
181
191
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
182
192
  earlyStatuses = new Map();
193
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
194
+ failedActions = new Map();
195
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
196
+ serverCopyTimer = null;
197
+ serverCopyRetries = 0;
198
+ serverCopySyncing = false;
183
199
 
184
200
  /**
185
201
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -269,6 +285,13 @@ class WidgetStore {
269
285
  next.inAppNotification = null;
270
286
  }
271
287
  this.state = next;
288
+
289
+ // Recorded here because a thread opens from many places (home cards, the
290
+ // list, the Messages tab, posts) and each would otherwise have to say so.
291
+ const intoThread = next.view === 'chat' || next.view === 'prechat';
292
+ if (intoThread && previous.view !== 'chat' && previous.view !== 'prechat') {
293
+ this.chatOpenedFrom = previous.isOpen && (previous.view === 'home' || previous.view === 'conversations') ? previous.view : null;
294
+ }
272
295
  if (next.unreadCount !== previous.unreadCount) {
273
296
  this.events.emit(_events.RepzoWidgetEvents.UnreadCountDidChange, {
274
297
  count: next.unreadCount
@@ -811,7 +834,9 @@ class WidgetStore {
811
834
  this.threadGeneration += 1;
812
835
  this.conversationCreation = null;
813
836
  this.draftEvents = [];
837
+ this.failedActions.clear();
814
838
  this.cancelMarkRead();
839
+ this.cancelServerCopySync();
815
840
  }
816
841
  requireBootConfig() {
817
842
  if (!this.bootConfig) {
@@ -1127,7 +1152,10 @@ class WidgetStore {
1127
1152
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1128
1153
  metadata: this.hydrateMetadata(data?.metadata || null),
1129
1154
  status: 'delivered',
1130
- createdAt: data?.createdAt || new Date().toISOString()
1155
+ createdAt: data?.createdAt || new Date().toISOString(),
1156
+ ...(typeof data?.clientMessageId === 'string' ? {
1157
+ clientMessageId: data.clientMessageId
1158
+ } : {})
1131
1159
  };
1132
1160
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1133
1161
  if (clearsTyping) this.clearTypingTimer();
@@ -1158,10 +1186,11 @@ class WidgetStore {
1158
1186
  messageCount: conversation.messageCount + 1
1159
1187
  } : conversation) : current.conversations;
1160
1188
  if (onScreen) {
1161
- const alreadyPresent = current.messages.some(existing => existing.id === message.id);
1189
+ const shown = adoptOwnServerRows(current.messages, [message]);
1190
+ const alreadyPresent = shown.some(existing => existing.id === message.id);
1162
1191
  return {
1163
1192
  conversations,
1164
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1193
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1165
1194
  agentTyping: clearsTyping ? false : current.agentTyping
1166
1195
  };
1167
1196
  }
@@ -1400,6 +1429,14 @@ class WidgetStore {
1400
1429
  } : {})
1401
1430
  });
1402
1431
  }
1432
+
1433
+ /**
1434
+ * The thread's back button: the screen it was opened from, or the
1435
+ * conversation list when it was opened from outside the messenger.
1436
+ */
1437
+ leaveConversation() {
1438
+ this.setView(this.chatOpenedFrom ?? (this.state.activeTab === 'messages' ? 'conversations' : 'home'));
1439
+ }
1403
1440
  setActiveTab(tab) {
1404
1441
  if (tab === 'home') {
1405
1442
  this.setState({
@@ -1564,11 +1601,12 @@ class WidgetStore {
1564
1601
  this.setState(current => {
1565
1602
  // Live replies and optimistic sends may have arrived since this page
1566
1603
  // was requested. Keep them, preferring the live copy for shared ids.
1567
- const liveIds = new Set(current.messages.map(message => message.id));
1568
1604
  const page = this.hydrateMessages(result.data);
1605
+ const live = adoptOwnServerRows(current.messages, page);
1606
+ const liveIds = new Set(live.map(message => message.id));
1569
1607
  const history = page.filter(message => !liveIds.has(message.id));
1570
1608
  return {
1571
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1609
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1572
1610
  messagesLoading: false,
1573
1611
  hasMoreMessages: result.meta.hasMore,
1574
1612
  messageCursor: result.meta.nextCursor
@@ -1636,14 +1674,15 @@ class WidgetStore {
1636
1674
  if (this.state.activeConversationId !== conversationId) return;
1637
1675
  const fresh = this.hydrateMessages(result.data);
1638
1676
  this.setState(current => {
1639
- const known = new Set(current.messages.map(message => message.id));
1677
+ const own = adoptOwnServerRows(current.messages, fresh);
1678
+ const known = new Set(own.map(message => message.id));
1640
1679
  const missing = fresh.filter(message => !known.has(message.id));
1641
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1680
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1642
1681
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1643
1682
  messages: upgraded
1644
1683
  };
1645
- const local = current.messages.filter(isLocalMessage);
1646
- const hasServerMessages = local.length < current.messages.length;
1684
+ const local = own.filter(_conversations.isLocalMessage);
1685
+ const hasServerMessages = local.length < own.length;
1647
1686
  // No overlap means the gap is longer than a page: restart from the
1648
1687
  // newest page (keeping unsent bubbles) rather than leave a hole.
1649
1688
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1668,6 +1707,40 @@ class WidgetStore {
1668
1707
  }
1669
1708
  }
1670
1709
 
1710
+ /**
1711
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1712
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1713
+ * open. When the stream's echo hasn't brought that copy, re-read the
1714
+ * thread, and try again a few times while Workstation is still storing it.
1715
+ */
1716
+ async syncServerCopies() {
1717
+ this.cancelServerCopySync();
1718
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1719
+ // A send still in flight would come back as a stored row beside its own
1720
+ // pending bubble. It syncs again when it settles.
1721
+ if (this.state.messages.some(message => (0, _conversations.isLocalMessage)(message) && message.status === 'pending')) return;
1722
+ // Off screen: the thread reloads in full when it's opened again.
1723
+ const conversationId = this.state.activeConversationId;
1724
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1725
+ const generation = this.threadGeneration;
1726
+ this.serverCopySyncing = true;
1727
+ try {
1728
+ // A no-op while a page load is in flight; the retry below covers it.
1729
+ await this.refreshOpenThread();
1730
+ } finally {
1731
+ this.serverCopySyncing = false;
1732
+ }
1733
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1734
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1735
+ if (delay === undefined) return;
1736
+ this.serverCopyRetries += 1;
1737
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1738
+ }
1739
+ cancelServerCopySync() {
1740
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
1741
+ this.serverCopyTimer = null;
1742
+ }
1743
+
1671
1744
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1672
1745
  hydrateMessages(page) {
1673
1746
  return page.map(message => ({
@@ -1715,9 +1788,11 @@ class WidgetStore {
1715
1788
 
1716
1789
  /**
1717
1790
  * Rejects when the message wasn't sent, so the composer can keep
1718
- * attachments for a retry. A text-only bubble is marked failed; one with
1719
- * attachments is removed instead — the composer's chips are the retry, and
1720
- * keeping both would leave a duplicate after a successful resend.
1791
+ * attachments for a retry. A text-only bubble is marked failed and can be
1792
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
1793
+ * composer's chips are the retry, and keeping both would leave a duplicate
1794
+ * after a successful resend. Likewise an agent UI action replaces the
1795
+ * failed bubble of the same action, since its card stays tappable.
1721
1796
  */
1722
1797
  async sendMessage(body, attachments = [], agentUiAction) {
1723
1798
  const trimmed = body.trim();
@@ -1725,7 +1800,6 @@ class WidgetStore {
1725
1800
  const {
1726
1801
  visitorProfile
1727
1802
  } = this.state;
1728
- const generation = this.threadGeneration;
1729
1803
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1730
1804
  const optimistic = {
1731
1805
  id: tempId,
@@ -1749,20 +1823,58 @@ class WidgetStore {
1749
1823
  status: 'pending',
1750
1824
  createdAt: new Date().toISOString()
1751
1825
  };
1826
+
1827
+ // Tapping the card again is the retry: offering the old bubble's retry
1828
+ // too would run the action twice (two items in the cart, two bookings).
1829
+ const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
1830
+ superseded.forEach(messageId => this.failedActions.delete(messageId));
1752
1831
  this.setState(current => ({
1753
- messages: [...current.messages, optimistic],
1832
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1754
1833
  prefillText: ''
1755
1834
  }));
1756
1835
  this.track('widget_message_sent', {
1757
1836
  hasText: !!trimmed,
1758
1837
  attachmentCount: attachments.length
1759
1838
  });
1760
- const markFailed = () => this.setState(current => ({
1761
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1762
- ...message,
1763
- status: 'failed'
1764
- } : message)
1839
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1840
+ }
1841
+
1842
+ /**
1843
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
1844
+ * since it now goes out after anything sent meanwhile. Rejects like
1845
+ * `sendMessage` when it fails again; the bubble is then failed once more.
1846
+ */
1847
+ async retryMessage(messageId) {
1848
+ const failed = this.state.messages.find(message => message.id === messageId && (0, _conversations.canRetryMessage)(message));
1849
+ if (!failed) return;
1850
+ const agentUiAction = this.failedActions.get(messageId);
1851
+ this.failedActions.delete(messageId);
1852
+ const pending = {
1853
+ ...failed,
1854
+ status: 'pending',
1855
+ createdAt: new Date().toISOString()
1856
+ };
1857
+ this.setState(current => ({
1858
+ messages: [...current.messages.filter(message => message.id !== messageId), pending]
1765
1859
  }));
1860
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
1861
+ }
1862
+
1863
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
1864
+ async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
1865
+ const {
1866
+ visitorProfile
1867
+ } = this.state;
1868
+ const generation = this.threadGeneration;
1869
+ const markFailed = () => {
1870
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
1871
+ this.setState(current => ({
1872
+ messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1873
+ ...message,
1874
+ status: 'failed'
1875
+ } : message)
1876
+ }));
1877
+ };
1766
1878
  const epoch = this.epoch;
1767
1879
 
1768
1880
  // A draft's first send creates the conversation. Anything sent from the
@@ -1788,24 +1900,41 @@ class WidgetStore {
1788
1900
  }
1789
1901
  let createdId = null;
1790
1902
  try {
1903
+ // Local files go up first; the message names each by its upload token.
1904
+ const sentAttachments = await Promise.all(attachments.map(async ({
1905
+ name,
1906
+ url,
1907
+ fileUri,
1908
+ contentType,
1909
+ size
1910
+ }) => fileUri ? {
1911
+ name,
1912
+ contentType,
1913
+ size,
1914
+ uploadToken: await this.client.uploadAttachment({
1915
+ uri: fileUri,
1916
+ name,
1917
+ contentType,
1918
+ size
1919
+ })
1920
+ } : {
1921
+ name,
1922
+ url,
1923
+ contentType,
1924
+ size
1925
+ }));
1926
+ if (this.isStale(epoch)) return;
1791
1927
  const result = await this.client.sendMessage({
1792
1928
  conversationId: conversationId || undefined,
1793
1929
  forceNewConversation: !conversationId,
1794
1930
  body: trimmed,
1795
- attachments: attachments.length > 0 ? attachments.map(({
1796
- name,
1797
- url,
1798
- contentType,
1799
- size
1800
- }) => ({
1801
- name,
1802
- url,
1803
- contentType,
1804
- size
1805
- })) : undefined,
1931
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1806
1932
  visitorName: visitorProfile.name,
1807
1933
  visitorEmail: visitorProfile.email,
1808
- agentUiAction
1934
+ agentUiAction,
1935
+ // The bubble's id, the same on every retry: a newer Workstation stores
1936
+ // the message once even when an earlier attempt's response was lost.
1937
+ clientMessageId: tempId
1809
1938
  });
1810
1939
  if (this.isStale(epoch)) return;
1811
1940
  if (ownCreation) createdId = result.conversationId;
@@ -1842,8 +1971,15 @@ class WidgetStore {
1842
1971
  } : {})
1843
1972
  };
1844
1973
  });
1974
+
1975
+ // New files to fetch the stored copy of: a fresh round of re-reads.
1976
+ const sent = this.state.messages.find(message => message.id === result.messageId);
1977
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1845
1978
  } catch (error) {
1846
1979
  if (this.isStale(epoch)) throw error;
1980
+ // The response was lost, but its stored row has already taken the
1981
+ // bubble's place (newer Workstation deployments): it was sent.
1982
+ if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
1847
1983
  markFailed();
1848
1984
  throw error;
1849
1985
  } finally {
@@ -1854,6 +1990,8 @@ class WidgetStore {
1854
1990
  // Anything held back for the draft now has a home (or goes to unread).
1855
1991
  this.replayDraftEvents();
1856
1992
  }
1993
+ // Waits for every send to settle, so this runs when the last one does.
1994
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1857
1995
  }
1858
1996
  }
1859
1997
  async identifyVisitor(data) {
@@ -2328,11 +2466,6 @@ function createConversationCreation(generation) {
2328
2466
  };
2329
2467
  }
2330
2468
 
2331
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2332
- function isLocalMessage(message) {
2333
- return message.id.startsWith('temp-');
2334
- }
2335
-
2336
2469
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2337
2470
  function adoptServerStatuses(messages, server) {
2338
2471
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2386,17 +2519,82 @@ function adoptServerAttachments(messages, server) {
2386
2519
  let changed = false;
2387
2520
  const next = messages.map(message => {
2388
2521
  const copy = serverCopies.get(message.id);
2389
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2390
- if (!copy || !hasLocal) return message;
2522
+ if (!copy || !hasLocalAttachments(message)) return message;
2523
+ const attachments = mergeAttachments(message.attachments, copy.attachments);
2524
+ if (attachments === message.attachments) return message;
2391
2525
  changed = true;
2392
2526
  return {
2393
2527
  ...message,
2394
- attachments: copy.attachments
2528
+ attachments
2395
2529
  };
2396
2530
  });
2397
2531
  return changed ? next : messages;
2398
2532
  }
2399
2533
 
2534
+ /**
2535
+ * Each attachment's server copy, except one without a usable URL: that would
2536
+ * only blank its local preview, so the preview stays until a later copy has it.
2537
+ * Returns `local` itself when nothing changes.
2538
+ */
2539
+ function mergeAttachments(local, server) {
2540
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2541
+ if (local.length !== server.length) {
2542
+ return server.every(attachment => attachment.url) ? server : local;
2543
+ }
2544
+ const merged = server.map((attachment, index) => {
2545
+ const own = local[index];
2546
+ return isLocalAttachment(own) && attachment.url ? attachment : own;
2547
+ });
2548
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2549
+ }
2550
+
2551
+ /**
2552
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2553
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2554
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2555
+ * for a message that was stored although its response was lost.
2556
+ */
2557
+ function adoptOwnServerRows(messages, server) {
2558
+ const rows = new Map();
2559
+ server.forEach(row => {
2560
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2561
+ });
2562
+ if (rows.size === 0) return messages;
2563
+ const shown = new Set(messages.map(message => message.id));
2564
+ let changed = false;
2565
+ const next = [];
2566
+ messages.forEach(message => {
2567
+ const row = (0, _conversations.isLocalMessage)(message) ? rows.get(message.id) : undefined;
2568
+ if (!row) {
2569
+ next.push(message);
2570
+ return;
2571
+ }
2572
+ changed = true;
2573
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2574
+ if (shown.has(row.id)) return;
2575
+ next.push({
2576
+ ...row,
2577
+ attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
2578
+ });
2579
+ });
2580
+ return changed ? next : messages;
2581
+ }
2582
+
2583
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2584
+ function isLocalAttachment(attachment) {
2585
+ return !/^https?:\/\//i.test(attachment.url);
2586
+ }
2587
+
2588
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2589
+ function hasLocalAttachments(message) {
2590
+ return !!message.attachments?.some(isLocalAttachment);
2591
+ }
2592
+
2593
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2594
+ function isAwaitingServerCopy(message) {
2595
+ return !(0, _conversations.isLocalMessage)(message) && hasLocalAttachments(message);
2596
+ }
2597
+
2400
2598
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2401
2599
  function sortByCreatedAt(messages) {
2402
2600
  const time = message => Date.parse(message.createdAt) || 0;