@repzo/widget-react-native 1.3.0 → 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 (117) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +8 -4
  3. package/lib/commonjs/api/client.js +40 -0
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/attachments/pickers.js +80 -23
  6. package/lib/commonjs/attachments/pickers.js.map +1 -1
  7. package/lib/commonjs/i18n.js +2 -0
  8. package/lib/commonjs/i18n.js.map +1 -1
  9. package/lib/commonjs/locales.js +1 -0
  10. package/lib/commonjs/locales.js.map +1 -1
  11. package/lib/commonjs/state/widget-store.js +216 -40
  12. package/lib/commonjs/state/widget-store.js.map +1 -1
  13. package/lib/commonjs/types.js.map +1 -1
  14. package/lib/commonjs/ui/WidgetProvider.js +7 -3
  15. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  16. package/lib/commonjs/ui/components/ImageViewer.js +3 -2
  17. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  18. package/lib/commonjs/ui/components/icons.js +22 -16
  19. package/lib/commonjs/ui/components/icons.js.map +1 -1
  20. package/lib/commonjs/ui/screens/chat/ChatView.js +36 -14
  21. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  22. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +12 -2
  23. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  24. package/lib/commonjs/ui/styles/theme.js +1 -0
  25. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  26. package/lib/commonjs/ui/utils/conversations.js +17 -1
  27. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  28. package/lib/module/api/client.js +40 -0
  29. package/lib/module/api/client.js.map +1 -1
  30. package/lib/module/attachments/pickers.js +80 -23
  31. package/lib/module/attachments/pickers.js.map +1 -1
  32. package/lib/module/i18n.js +2 -0
  33. package/lib/module/i18n.js.map +1 -1
  34. package/lib/module/locales.js +1 -0
  35. package/lib/module/locales.js.map +1 -1
  36. package/lib/module/state/widget-store.js +216 -40
  37. package/lib/module/state/widget-store.js.map +1 -1
  38. package/lib/module/types.js.map +1 -1
  39. package/lib/module/ui/WidgetProvider.js +7 -3
  40. package/lib/module/ui/WidgetProvider.js.map +1 -1
  41. package/lib/module/ui/components/ImageViewer.js +3 -2
  42. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  43. package/lib/module/ui/components/icons.js +21 -15
  44. package/lib/module/ui/components/icons.js.map +1 -1
  45. package/lib/module/ui/screens/chat/ChatView.js +38 -16
  46. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  47. package/lib/module/ui/screens/chat/ChatView.styles.js +12 -2
  48. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  49. package/lib/module/ui/styles/theme.js +1 -0
  50. package/lib/module/ui/styles/theme.js.map +1 -1
  51. package/lib/module/ui/utils/conversations.js +15 -1
  52. package/lib/module/ui/utils/conversations.js.map +1 -1
  53. package/lib/typescript/commonjs/api/client.d.ts +27 -1
  54. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
  56. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/i18n.d.ts +1 -0
  58. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/state/widget-store.d.ts +27 -3
  61. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/types.d.ts +17 -0
  63. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  67. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  69. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +10 -2
  71. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  75. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  77. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  78. package/lib/typescript/module/api/client.d.ts +27 -1
  79. package/lib/typescript/module/api/client.d.ts.map +1 -1
  80. package/lib/typescript/module/attachments/pickers.d.ts +22 -10
  81. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  82. package/lib/typescript/module/i18n.d.ts +1 -0
  83. package/lib/typescript/module/i18n.d.ts.map +1 -1
  84. package/lib/typescript/module/locales.d.ts.map +1 -1
  85. package/lib/typescript/module/state/widget-store.d.ts +27 -3
  86. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  87. package/lib/typescript/module/types.d.ts +17 -0
  88. package/lib/typescript/module/types.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  92. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  94. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  95. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +10 -2
  96. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  98. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  100. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  101. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  102. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/api/client.ts +66 -2
  105. package/src/attachments/pickers.ts +87 -25
  106. package/src/i18n.ts +2 -0
  107. package/src/locales.ts +1 -0
  108. package/src/state/widget-store.ts +210 -33
  109. package/src/types.ts +17 -0
  110. package/src/ui/WidgetProvider.tsx +15 -4
  111. package/src/ui/components/ImageViewer.tsx +10 -6
  112. package/src/ui/components/icons.tsx +10 -12
  113. package/src/ui/screens/chat/ChatView.styles.ts +12 -2
  114. package/src/ui/screens/chat/ChatView.tsx +45 -22
  115. package/src/ui/screens/chat/ChatView.types.ts +4 -0
  116. package/src/ui/styles/theme.ts +6 -0
  117. 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.3.0';
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
 
@@ -187,6 +190,12 @@ class WidgetStore {
187
190
  seenMessageIds = [];
188
191
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
189
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;
190
199
 
191
200
  /**
192
201
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -825,7 +834,9 @@ class WidgetStore {
825
834
  this.threadGeneration += 1;
826
835
  this.conversationCreation = null;
827
836
  this.draftEvents = [];
837
+ this.failedActions.clear();
828
838
  this.cancelMarkRead();
839
+ this.cancelServerCopySync();
829
840
  }
830
841
  requireBootConfig() {
831
842
  if (!this.bootConfig) {
@@ -1141,7 +1152,10 @@ class WidgetStore {
1141
1152
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1142
1153
  metadata: this.hydrateMetadata(data?.metadata || null),
1143
1154
  status: 'delivered',
1144
- createdAt: data?.createdAt || new Date().toISOString()
1155
+ createdAt: data?.createdAt || new Date().toISOString(),
1156
+ ...(typeof data?.clientMessageId === 'string' ? {
1157
+ clientMessageId: data.clientMessageId
1158
+ } : {})
1145
1159
  };
1146
1160
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1147
1161
  if (clearsTyping) this.clearTypingTimer();
@@ -1172,10 +1186,11 @@ class WidgetStore {
1172
1186
  messageCount: conversation.messageCount + 1
1173
1187
  } : conversation) : current.conversations;
1174
1188
  if (onScreen) {
1175
- 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);
1176
1191
  return {
1177
1192
  conversations,
1178
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1193
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1179
1194
  agentTyping: clearsTyping ? false : current.agentTyping
1180
1195
  };
1181
1196
  }
@@ -1586,11 +1601,12 @@ class WidgetStore {
1586
1601
  this.setState(current => {
1587
1602
  // Live replies and optimistic sends may have arrived since this page
1588
1603
  // was requested. Keep them, preferring the live copy for shared ids.
1589
- const liveIds = new Set(current.messages.map(message => message.id));
1590
1604
  const page = this.hydrateMessages(result.data);
1605
+ const live = adoptOwnServerRows(current.messages, page);
1606
+ const liveIds = new Set(live.map(message => message.id));
1591
1607
  const history = page.filter(message => !liveIds.has(message.id));
1592
1608
  return {
1593
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1609
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1594
1610
  messagesLoading: false,
1595
1611
  hasMoreMessages: result.meta.hasMore,
1596
1612
  messageCursor: result.meta.nextCursor
@@ -1658,14 +1674,15 @@ class WidgetStore {
1658
1674
  if (this.state.activeConversationId !== conversationId) return;
1659
1675
  const fresh = this.hydrateMessages(result.data);
1660
1676
  this.setState(current => {
1661
- 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));
1662
1679
  const missing = fresh.filter(message => !known.has(message.id));
1663
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1680
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1664
1681
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1665
1682
  messages: upgraded
1666
1683
  };
1667
- const local = current.messages.filter(isLocalMessage);
1668
- const hasServerMessages = local.length < current.messages.length;
1684
+ const local = own.filter(_conversations.isLocalMessage);
1685
+ const hasServerMessages = local.length < own.length;
1669
1686
  // No overlap means the gap is longer than a page: restart from the
1670
1687
  // newest page (keeping unsent bubbles) rather than leave a hole.
1671
1688
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1690,6 +1707,40 @@ class WidgetStore {
1690
1707
  }
1691
1708
  }
1692
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
+
1693
1744
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1694
1745
  hydrateMessages(page) {
1695
1746
  return page.map(message => ({
@@ -1737,9 +1788,11 @@ class WidgetStore {
1737
1788
 
1738
1789
  /**
1739
1790
  * Rejects when the message wasn't sent, so the composer can keep
1740
- * attachments for a retry. A text-only bubble is marked failed; one with
1741
- * attachments is removed instead — the composer's chips are the retry, and
1742
- * 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.
1743
1796
  */
1744
1797
  async sendMessage(body, attachments = [], agentUiAction) {
1745
1798
  const trimmed = body.trim();
@@ -1747,7 +1800,6 @@ class WidgetStore {
1747
1800
  const {
1748
1801
  visitorProfile
1749
1802
  } = this.state;
1750
- const generation = this.threadGeneration;
1751
1803
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1752
1804
  const optimistic = {
1753
1805
  id: tempId,
@@ -1771,20 +1823,58 @@ class WidgetStore {
1771
1823
  status: 'pending',
1772
1824
  createdAt: new Date().toISOString()
1773
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));
1774
1831
  this.setState(current => ({
1775
- messages: [...current.messages, optimistic],
1832
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1776
1833
  prefillText: ''
1777
1834
  }));
1778
1835
  this.track('widget_message_sent', {
1779
1836
  hasText: !!trimmed,
1780
1837
  attachmentCount: attachments.length
1781
1838
  });
1782
- const markFailed = () => this.setState(current => ({
1783
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1784
- ...message,
1785
- status: 'failed'
1786
- } : 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]
1787
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
+ };
1788
1878
  const epoch = this.epoch;
1789
1879
 
1790
1880
  // A draft's first send creates the conversation. Anything sent from the
@@ -1810,24 +1900,41 @@ class WidgetStore {
1810
1900
  }
1811
1901
  let createdId = null;
1812
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;
1813
1927
  const result = await this.client.sendMessage({
1814
1928
  conversationId: conversationId || undefined,
1815
1929
  forceNewConversation: !conversationId,
1816
1930
  body: trimmed,
1817
- attachments: attachments.length > 0 ? attachments.map(({
1818
- name,
1819
- url,
1820
- contentType,
1821
- size
1822
- }) => ({
1823
- name,
1824
- url,
1825
- contentType,
1826
- size
1827
- })) : undefined,
1931
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1828
1932
  visitorName: visitorProfile.name,
1829
1933
  visitorEmail: visitorProfile.email,
1830
- 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
1831
1938
  });
1832
1939
  if (this.isStale(epoch)) return;
1833
1940
  if (ownCreation) createdId = result.conversationId;
@@ -1864,8 +1971,15 @@ class WidgetStore {
1864
1971
  } : {})
1865
1972
  };
1866
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;
1867
1978
  } catch (error) {
1868
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;
1869
1983
  markFailed();
1870
1984
  throw error;
1871
1985
  } finally {
@@ -1876,6 +1990,8 @@ class WidgetStore {
1876
1990
  // Anything held back for the draft now has a home (or goes to unread).
1877
1991
  this.replayDraftEvents();
1878
1992
  }
1993
+ // Waits for every send to settle, so this runs when the last one does.
1994
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1879
1995
  }
1880
1996
  }
1881
1997
  async identifyVisitor(data) {
@@ -2350,11 +2466,6 @@ function createConversationCreation(generation) {
2350
2466
  };
2351
2467
  }
2352
2468
 
2353
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2354
- function isLocalMessage(message) {
2355
- return message.id.startsWith('temp-');
2356
- }
2357
-
2358
2469
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2359
2470
  function adoptServerStatuses(messages, server) {
2360
2471
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2408,17 +2519,82 @@ function adoptServerAttachments(messages, server) {
2408
2519
  let changed = false;
2409
2520
  const next = messages.map(message => {
2410
2521
  const copy = serverCopies.get(message.id);
2411
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2412
- 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;
2413
2525
  changed = true;
2414
2526
  return {
2415
2527
  ...message,
2416
- attachments: copy.attachments
2528
+ attachments
2417
2529
  };
2418
2530
  });
2419
2531
  return changed ? next : messages;
2420
2532
  }
2421
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
+
2422
2598
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2423
2599
  function sortByCreatedAt(messages) {
2424
2600
  const time = message => Date.parse(message.createdAt) || 0;