@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
@@ -19,10 +19,11 @@ import { PushManager } from "../push/push-notifications.js";
19
19
  import { CONFIG_KEY, LEGACY_CONFIG_KEY, SecureStorage, Storage, UNREAD_KEY, USER_KEY } from "../storage.js";
20
20
  import { setAttachmentOptions } from "../attachments/pickers.js";
21
21
  import { DEFAULT_BASE_URL } from "../types.js";
22
+ import { canRetryMessage, isLocalMessage } from "../ui/utils/conversations.js";
22
23
  import { setOpenUrlHandler } from "../ui/utils/safe-url.js";
23
24
 
24
25
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
25
- const SDK_VERSION = '@repzo/widget-react-native@1.2.2';
26
+ const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
26
27
 
27
28
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
28
29
  function sessionPlatform() {
@@ -52,6 +53,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
52
53
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
53
54
  /** Live events held back while a draft's first send is creating its thread. */
54
55
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
56
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
57
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
55
58
  /** How long a post stays up after a reaction, thanking them, before it goes. */
56
59
  export const POST_THANKS_MS = 1600;
57
60
 
@@ -168,6 +171,13 @@ export class WidgetStore {
168
171
  /** True once `currentUserKey` mirrors storage — memory is then authoritative. */
169
172
  userKeyLoaded = false;
170
173
  pendingConversationId = null;
174
+
175
+ /**
176
+ * The screen a thread was opened from, so its back button returns there.
177
+ * Null when it was opened with the messenger closed (a push, a notification
178
+ * card, `presentConversation`): there is nothing to go back to.
179
+ */
180
+ chatOpenedFrom = null;
171
181
  appStateSubscription = null;
172
182
 
173
183
  /** When the app last went to the background (0: hasn't this session). */
@@ -177,6 +187,12 @@ export class WidgetStore {
177
187
  seenMessageIds = [];
178
188
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
179
189
  earlyStatuses = new Map();
190
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
191
+ failedActions = new Map();
192
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
193
+ serverCopyTimer = null;
194
+ serverCopyRetries = 0;
195
+ serverCopySyncing = false;
180
196
 
181
197
  /**
182
198
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -266,6 +282,13 @@ export class WidgetStore {
266
282
  next.inAppNotification = null;
267
283
  }
268
284
  this.state = next;
285
+
286
+ // Recorded here because a thread opens from many places (home cards, the
287
+ // list, the Messages tab, posts) and each would otherwise have to say so.
288
+ const intoThread = next.view === 'chat' || next.view === 'prechat';
289
+ if (intoThread && previous.view !== 'chat' && previous.view !== 'prechat') {
290
+ this.chatOpenedFrom = previous.isOpen && (previous.view === 'home' || previous.view === 'conversations') ? previous.view : null;
291
+ }
269
292
  if (next.unreadCount !== previous.unreadCount) {
270
293
  this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, {
271
294
  count: next.unreadCount
@@ -808,7 +831,9 @@ export class WidgetStore {
808
831
  this.threadGeneration += 1;
809
832
  this.conversationCreation = null;
810
833
  this.draftEvents = [];
834
+ this.failedActions.clear();
811
835
  this.cancelMarkRead();
836
+ this.cancelServerCopySync();
812
837
  }
813
838
  requireBootConfig() {
814
839
  if (!this.bootConfig) {
@@ -1124,7 +1149,10 @@ export class WidgetStore {
1124
1149
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1125
1150
  metadata: this.hydrateMetadata(data?.metadata || null),
1126
1151
  status: 'delivered',
1127
- createdAt: data?.createdAt || new Date().toISOString()
1152
+ createdAt: data?.createdAt || new Date().toISOString(),
1153
+ ...(typeof data?.clientMessageId === 'string' ? {
1154
+ clientMessageId: data.clientMessageId
1155
+ } : {})
1128
1156
  };
1129
1157
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1130
1158
  if (clearsTyping) this.clearTypingTimer();
@@ -1155,10 +1183,11 @@ export class WidgetStore {
1155
1183
  messageCount: conversation.messageCount + 1
1156
1184
  } : conversation) : current.conversations;
1157
1185
  if (onScreen) {
1158
- const alreadyPresent = current.messages.some(existing => existing.id === message.id);
1186
+ const shown = adoptOwnServerRows(current.messages, [message]);
1187
+ const alreadyPresent = shown.some(existing => existing.id === message.id);
1159
1188
  return {
1160
1189
  conversations,
1161
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1190
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1162
1191
  agentTyping: clearsTyping ? false : current.agentTyping
1163
1192
  };
1164
1193
  }
@@ -1397,6 +1426,14 @@ export class WidgetStore {
1397
1426
  } : {})
1398
1427
  });
1399
1428
  }
1429
+
1430
+ /**
1431
+ * The thread's back button: the screen it was opened from, or the
1432
+ * conversation list when it was opened from outside the messenger.
1433
+ */
1434
+ leaveConversation() {
1435
+ this.setView(this.chatOpenedFrom ?? (this.state.activeTab === 'messages' ? 'conversations' : 'home'));
1436
+ }
1400
1437
  setActiveTab(tab) {
1401
1438
  if (tab === 'home') {
1402
1439
  this.setState({
@@ -1561,11 +1598,12 @@ export class WidgetStore {
1561
1598
  this.setState(current => {
1562
1599
  // Live replies and optimistic sends may have arrived since this page
1563
1600
  // was requested. Keep them, preferring the live copy for shared ids.
1564
- const liveIds = new Set(current.messages.map(message => message.id));
1565
1601
  const page = this.hydrateMessages(result.data);
1602
+ const live = adoptOwnServerRows(current.messages, page);
1603
+ const liveIds = new Set(live.map(message => message.id));
1566
1604
  const history = page.filter(message => !liveIds.has(message.id));
1567
1605
  return {
1568
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1606
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1569
1607
  messagesLoading: false,
1570
1608
  hasMoreMessages: result.meta.hasMore,
1571
1609
  messageCursor: result.meta.nextCursor
@@ -1633,14 +1671,15 @@ export class WidgetStore {
1633
1671
  if (this.state.activeConversationId !== conversationId) return;
1634
1672
  const fresh = this.hydrateMessages(result.data);
1635
1673
  this.setState(current => {
1636
- const known = new Set(current.messages.map(message => message.id));
1674
+ const own = adoptOwnServerRows(current.messages, fresh);
1675
+ const known = new Set(own.map(message => message.id));
1637
1676
  const missing = fresh.filter(message => !known.has(message.id));
1638
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1677
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1639
1678
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1640
1679
  messages: upgraded
1641
1680
  };
1642
- const local = current.messages.filter(isLocalMessage);
1643
- const hasServerMessages = local.length < current.messages.length;
1681
+ const local = own.filter(isLocalMessage);
1682
+ const hasServerMessages = local.length < own.length;
1644
1683
  // No overlap means the gap is longer than a page: restart from the
1645
1684
  // newest page (keeping unsent bubbles) rather than leave a hole.
1646
1685
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1665,6 +1704,40 @@ export class WidgetStore {
1665
1704
  }
1666
1705
  }
1667
1706
 
1707
+ /**
1708
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1709
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1710
+ * open. When the stream's echo hasn't brought that copy, re-read the
1711
+ * thread, and try again a few times while Workstation is still storing it.
1712
+ */
1713
+ async syncServerCopies() {
1714
+ this.cancelServerCopySync();
1715
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1716
+ // A send still in flight would come back as a stored row beside its own
1717
+ // pending bubble. It syncs again when it settles.
1718
+ if (this.state.messages.some(message => isLocalMessage(message) && message.status === 'pending')) return;
1719
+ // Off screen: the thread reloads in full when it's opened again.
1720
+ const conversationId = this.state.activeConversationId;
1721
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1722
+ const generation = this.threadGeneration;
1723
+ this.serverCopySyncing = true;
1724
+ try {
1725
+ // A no-op while a page load is in flight; the retry below covers it.
1726
+ await this.refreshOpenThread();
1727
+ } finally {
1728
+ this.serverCopySyncing = false;
1729
+ }
1730
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1731
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1732
+ if (delay === undefined) return;
1733
+ this.serverCopyRetries += 1;
1734
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1735
+ }
1736
+ cancelServerCopySync() {
1737
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
1738
+ this.serverCopyTimer = null;
1739
+ }
1740
+
1668
1741
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1669
1742
  hydrateMessages(page) {
1670
1743
  return page.map(message => ({
@@ -1712,9 +1785,11 @@ export class WidgetStore {
1712
1785
 
1713
1786
  /**
1714
1787
  * Rejects when the message wasn't sent, so the composer can keep
1715
- * attachments for a retry. A text-only bubble is marked failed; one with
1716
- * attachments is removed instead — the composer's chips are the retry, and
1717
- * keeping both would leave a duplicate after a successful resend.
1788
+ * attachments for a retry. A text-only bubble is marked failed and can be
1789
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
1790
+ * composer's chips are the retry, and keeping both would leave a duplicate
1791
+ * after a successful resend. Likewise an agent UI action replaces the
1792
+ * failed bubble of the same action, since its card stays tappable.
1718
1793
  */
1719
1794
  async sendMessage(body, attachments = [], agentUiAction) {
1720
1795
  const trimmed = body.trim();
@@ -1722,7 +1797,6 @@ export class WidgetStore {
1722
1797
  const {
1723
1798
  visitorProfile
1724
1799
  } = this.state;
1725
- const generation = this.threadGeneration;
1726
1800
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1727
1801
  const optimistic = {
1728
1802
  id: tempId,
@@ -1746,20 +1820,58 @@ export class WidgetStore {
1746
1820
  status: 'pending',
1747
1821
  createdAt: new Date().toISOString()
1748
1822
  };
1823
+
1824
+ // Tapping the card again is the retry: offering the old bubble's retry
1825
+ // too would run the action twice (two items in the cart, two bookings).
1826
+ const superseded = agentUiAction ? [...this.failedActions].filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type).map(([messageId]) => messageId) : [];
1827
+ superseded.forEach(messageId => this.failedActions.delete(messageId));
1749
1828
  this.setState(current => ({
1750
- messages: [...current.messages, optimistic],
1829
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1751
1830
  prefillText: ''
1752
1831
  }));
1753
1832
  this.track('widget_message_sent', {
1754
1833
  hasText: !!trimmed,
1755
1834
  attachmentCount: attachments.length
1756
1835
  });
1757
- const markFailed = () => this.setState(current => ({
1758
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1759
- ...message,
1760
- status: 'failed'
1761
- } : message)
1836
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1837
+ }
1838
+
1839
+ /**
1840
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
1841
+ * since it now goes out after anything sent meanwhile. Rejects like
1842
+ * `sendMessage` when it fails again; the bubble is then failed once more.
1843
+ */
1844
+ async retryMessage(messageId) {
1845
+ const failed = this.state.messages.find(message => message.id === messageId && canRetryMessage(message));
1846
+ if (!failed) return;
1847
+ const agentUiAction = this.failedActions.get(messageId);
1848
+ this.failedActions.delete(messageId);
1849
+ const pending = {
1850
+ ...failed,
1851
+ status: 'pending',
1852
+ createdAt: new Date().toISOString()
1853
+ };
1854
+ this.setState(current => ({
1855
+ messages: [...current.messages.filter(message => message.id !== messageId), pending]
1762
1856
  }));
1857
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
1858
+ }
1859
+
1860
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
1861
+ async deliverMessage(tempId, trimmed, attachments, agentUiAction) {
1862
+ const {
1863
+ visitorProfile
1864
+ } = this.state;
1865
+ const generation = this.threadGeneration;
1866
+ const markFailed = () => {
1867
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
1868
+ this.setState(current => ({
1869
+ messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1870
+ ...message,
1871
+ status: 'failed'
1872
+ } : message)
1873
+ }));
1874
+ };
1763
1875
  const epoch = this.epoch;
1764
1876
 
1765
1877
  // A draft's first send creates the conversation. Anything sent from the
@@ -1785,24 +1897,41 @@ export class WidgetStore {
1785
1897
  }
1786
1898
  let createdId = null;
1787
1899
  try {
1900
+ // Local files go up first; the message names each by its upload token.
1901
+ const sentAttachments = await Promise.all(attachments.map(async ({
1902
+ name,
1903
+ url,
1904
+ fileUri,
1905
+ contentType,
1906
+ size
1907
+ }) => fileUri ? {
1908
+ name,
1909
+ contentType,
1910
+ size,
1911
+ uploadToken: await this.client.uploadAttachment({
1912
+ uri: fileUri,
1913
+ name,
1914
+ contentType,
1915
+ size
1916
+ })
1917
+ } : {
1918
+ name,
1919
+ url,
1920
+ contentType,
1921
+ size
1922
+ }));
1923
+ if (this.isStale(epoch)) return;
1788
1924
  const result = await this.client.sendMessage({
1789
1925
  conversationId: conversationId || undefined,
1790
1926
  forceNewConversation: !conversationId,
1791
1927
  body: trimmed,
1792
- attachments: attachments.length > 0 ? attachments.map(({
1793
- name,
1794
- url,
1795
- contentType,
1796
- size
1797
- }) => ({
1798
- name,
1799
- url,
1800
- contentType,
1801
- size
1802
- })) : undefined,
1928
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1803
1929
  visitorName: visitorProfile.name,
1804
1930
  visitorEmail: visitorProfile.email,
1805
- agentUiAction
1931
+ agentUiAction,
1932
+ // The bubble's id, the same on every retry: a newer Workstation stores
1933
+ // the message once even when an earlier attempt's response was lost.
1934
+ clientMessageId: tempId
1806
1935
  });
1807
1936
  if (this.isStale(epoch)) return;
1808
1937
  if (ownCreation) createdId = result.conversationId;
@@ -1839,8 +1968,15 @@ export class WidgetStore {
1839
1968
  } : {})
1840
1969
  };
1841
1970
  });
1971
+
1972
+ // New files to fetch the stored copy of: a fresh round of re-reads.
1973
+ const sent = this.state.messages.find(message => message.id === result.messageId);
1974
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1842
1975
  } catch (error) {
1843
1976
  if (this.isStale(epoch)) throw error;
1977
+ // The response was lost, but its stored row has already taken the
1978
+ // bubble's place (newer Workstation deployments): it was sent.
1979
+ if (this.state.messages.some(message => message.clientMessageId === tempId)) return;
1844
1980
  markFailed();
1845
1981
  throw error;
1846
1982
  } finally {
@@ -1851,6 +1987,8 @@ export class WidgetStore {
1851
1987
  // Anything held back for the draft now has a home (or goes to unread).
1852
1988
  this.replayDraftEvents();
1853
1989
  }
1990
+ // Waits for every send to settle, so this runs when the last one does.
1991
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1854
1992
  }
1855
1993
  }
1856
1994
  async identifyVisitor(data) {
@@ -2324,11 +2462,6 @@ function createConversationCreation(generation) {
2324
2462
  };
2325
2463
  }
2326
2464
 
2327
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2328
- function isLocalMessage(message) {
2329
- return message.id.startsWith('temp-');
2330
- }
2331
-
2332
2465
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2333
2466
  function adoptServerStatuses(messages, server) {
2334
2467
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2382,17 +2515,82 @@ function adoptServerAttachments(messages, server) {
2382
2515
  let changed = false;
2383
2516
  const next = messages.map(message => {
2384
2517
  const copy = serverCopies.get(message.id);
2385
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2386
- if (!copy || !hasLocal) return message;
2518
+ if (!copy || !hasLocalAttachments(message)) return message;
2519
+ const attachments = mergeAttachments(message.attachments, copy.attachments);
2520
+ if (attachments === message.attachments) return message;
2387
2521
  changed = true;
2388
2522
  return {
2389
2523
  ...message,
2390
- attachments: copy.attachments
2524
+ attachments
2391
2525
  };
2392
2526
  });
2393
2527
  return changed ? next : messages;
2394
2528
  }
2395
2529
 
2530
+ /**
2531
+ * Each attachment's server copy, except one without a usable URL: that would
2532
+ * only blank its local preview, so the preview stays until a later copy has it.
2533
+ * Returns `local` itself when nothing changes.
2534
+ */
2535
+ function mergeAttachments(local, server) {
2536
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2537
+ if (local.length !== server.length) {
2538
+ return server.every(attachment => attachment.url) ? server : local;
2539
+ }
2540
+ const merged = server.map((attachment, index) => {
2541
+ const own = local[index];
2542
+ return isLocalAttachment(own) && attachment.url ? attachment : own;
2543
+ });
2544
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2545
+ }
2546
+
2547
+ /**
2548
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2549
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2550
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2551
+ * for a message that was stored although its response was lost.
2552
+ */
2553
+ function adoptOwnServerRows(messages, server) {
2554
+ const rows = new Map();
2555
+ server.forEach(row => {
2556
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2557
+ });
2558
+ if (rows.size === 0) return messages;
2559
+ const shown = new Set(messages.map(message => message.id));
2560
+ let changed = false;
2561
+ const next = [];
2562
+ messages.forEach(message => {
2563
+ const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
2564
+ if (!row) {
2565
+ next.push(message);
2566
+ return;
2567
+ }
2568
+ changed = true;
2569
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2570
+ if (shown.has(row.id)) return;
2571
+ next.push({
2572
+ ...row,
2573
+ attachments: row.attachments && message.attachments ? mergeAttachments(message.attachments, row.attachments) : row.attachments ?? message.attachments
2574
+ });
2575
+ });
2576
+ return changed ? next : messages;
2577
+ }
2578
+
2579
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2580
+ function isLocalAttachment(attachment) {
2581
+ return !/^https?:\/\//i.test(attachment.url);
2582
+ }
2583
+
2584
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2585
+ function hasLocalAttachments(message) {
2586
+ return !!message.attachments?.some(isLocalAttachment);
2587
+ }
2588
+
2589
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2590
+ function isAwaitingServerCopy(message) {
2591
+ return !isLocalMessage(message) && hasLocalAttachments(message);
2592
+ }
2593
+
2396
2594
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2397
2595
  function sortByCreatedAt(messages) {
2398
2596
  const time = message => Date.parse(message.createdAt) || 0;