@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
@@ -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.3.0';
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
 
@@ -184,6 +187,12 @@ export class WidgetStore {
184
187
  seenMessageIds = [];
185
188
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
186
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;
187
196
 
188
197
  /**
189
198
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -822,7 +831,9 @@ export class WidgetStore {
822
831
  this.threadGeneration += 1;
823
832
  this.conversationCreation = null;
824
833
  this.draftEvents = [];
834
+ this.failedActions.clear();
825
835
  this.cancelMarkRead();
836
+ this.cancelServerCopySync();
826
837
  }
827
838
  requireBootConfig() {
828
839
  if (!this.bootConfig) {
@@ -1138,7 +1149,10 @@ export class WidgetStore {
1138
1149
  attachments: this.hydrateAttachments(data?.attachments, messageId),
1139
1150
  metadata: this.hydrateMetadata(data?.metadata || null),
1140
1151
  status: 'delivered',
1141
- createdAt: data?.createdAt || new Date().toISOString()
1152
+ createdAt: data?.createdAt || new Date().toISOString(),
1153
+ ...(typeof data?.clientMessageId === 'string' ? {
1154
+ clientMessageId: data.clientMessageId
1155
+ } : {})
1142
1156
  };
1143
1157
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
1144
1158
  if (clearsTyping) this.clearTypingTimer();
@@ -1169,10 +1183,11 @@ export class WidgetStore {
1169
1183
  messageCount: conversation.messageCount + 1
1170
1184
  } : conversation) : current.conversations;
1171
1185
  if (onScreen) {
1172
- 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);
1173
1188
  return {
1174
1189
  conversations,
1175
- messages: alreadyPresent ? adoptServerAttachments(current.messages, [message]) : [...current.messages, message],
1190
+ messages: alreadyPresent ? adoptServerAttachments(shown, [message]) : [...shown, message],
1176
1191
  agentTyping: clearsTyping ? false : current.agentTyping
1177
1192
  };
1178
1193
  }
@@ -1583,11 +1598,12 @@ export class WidgetStore {
1583
1598
  this.setState(current => {
1584
1599
  // Live replies and optimistic sends may have arrived since this page
1585
1600
  // was requested. Keep them, preferring the live copy for shared ids.
1586
- const liveIds = new Set(current.messages.map(message => message.id));
1587
1601
  const page = this.hydrateMessages(result.data);
1602
+ const live = adoptOwnServerRows(current.messages, page);
1603
+ const liveIds = new Set(live.map(message => message.id));
1588
1604
  const history = page.filter(message => !liveIds.has(message.id));
1589
1605
  return {
1590
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1606
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1591
1607
  messagesLoading: false,
1592
1608
  hasMoreMessages: result.meta.hasMore,
1593
1609
  messageCursor: result.meta.nextCursor
@@ -1655,14 +1671,15 @@ export class WidgetStore {
1655
1671
  if (this.state.activeConversationId !== conversationId) return;
1656
1672
  const fresh = this.hydrateMessages(result.data);
1657
1673
  this.setState(current => {
1658
- 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));
1659
1676
  const missing = fresh.filter(message => !known.has(message.id));
1660
- const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh), fresh);
1677
+ const upgraded = adoptServerStatuses(adoptServerDeletions(adoptServerAttachments(own, fresh), fresh), fresh);
1661
1678
  if (missing.length === 0) return upgraded === current.messages ? {} : {
1662
1679
  messages: upgraded
1663
1680
  };
1664
- const local = current.messages.filter(isLocalMessage);
1665
- const hasServerMessages = local.length < current.messages.length;
1681
+ const local = own.filter(isLocalMessage);
1682
+ const hasServerMessages = local.length < own.length;
1666
1683
  // No overlap means the gap is longer than a page: restart from the
1667
1684
  // newest page (keeping unsent bubbles) rather than leave a hole.
1668
1685
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1687,6 +1704,40 @@ export class WidgetStore {
1687
1704
  }
1688
1705
  }
1689
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
+
1690
1741
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1691
1742
  hydrateMessages(page) {
1692
1743
  return page.map(message => ({
@@ -1734,9 +1785,11 @@ export class WidgetStore {
1734
1785
 
1735
1786
  /**
1736
1787
  * Rejects when the message wasn't sent, so the composer can keep
1737
- * attachments for a retry. A text-only bubble is marked failed; one with
1738
- * attachments is removed instead — the composer's chips are the retry, and
1739
- * 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.
1740
1793
  */
1741
1794
  async sendMessage(body, attachments = [], agentUiAction) {
1742
1795
  const trimmed = body.trim();
@@ -1744,7 +1797,6 @@ export class WidgetStore {
1744
1797
  const {
1745
1798
  visitorProfile
1746
1799
  } = this.state;
1747
- const generation = this.threadGeneration;
1748
1800
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1749
1801
  const optimistic = {
1750
1802
  id: tempId,
@@ -1768,20 +1820,58 @@ export class WidgetStore {
1768
1820
  status: 'pending',
1769
1821
  createdAt: new Date().toISOString()
1770
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));
1771
1828
  this.setState(current => ({
1772
- messages: [...current.messages, optimistic],
1829
+ messages: [...current.messages.filter(message => !superseded.includes(message.id)), optimistic],
1773
1830
  prefillText: ''
1774
1831
  }));
1775
1832
  this.track('widget_message_sent', {
1776
1833
  hasText: !!trimmed,
1777
1834
  attachmentCount: attachments.length
1778
1835
  });
1779
- const markFailed = () => this.setState(current => ({
1780
- messages: attachments.length > 0 ? current.messages.filter(message => message.id !== tempId) : current.messages.map(message => message.id === tempId ? {
1781
- ...message,
1782
- status: 'failed'
1783
- } : 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]
1784
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
+ };
1785
1875
  const epoch = this.epoch;
1786
1876
 
1787
1877
  // A draft's first send creates the conversation. Anything sent from the
@@ -1807,24 +1897,41 @@ export class WidgetStore {
1807
1897
  }
1808
1898
  let createdId = null;
1809
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;
1810
1924
  const result = await this.client.sendMessage({
1811
1925
  conversationId: conversationId || undefined,
1812
1926
  forceNewConversation: !conversationId,
1813
1927
  body: trimmed,
1814
- attachments: attachments.length > 0 ? attachments.map(({
1815
- name,
1816
- url,
1817
- contentType,
1818
- size
1819
- }) => ({
1820
- name,
1821
- url,
1822
- contentType,
1823
- size
1824
- })) : undefined,
1928
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1825
1929
  visitorName: visitorProfile.name,
1826
1930
  visitorEmail: visitorProfile.email,
1827
- 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
1828
1935
  });
1829
1936
  if (this.isStale(epoch)) return;
1830
1937
  if (ownCreation) createdId = result.conversationId;
@@ -1861,8 +1968,15 @@ export class WidgetStore {
1861
1968
  } : {})
1862
1969
  };
1863
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;
1864
1975
  } catch (error) {
1865
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;
1866
1980
  markFailed();
1867
1981
  throw error;
1868
1982
  } finally {
@@ -1873,6 +1987,8 @@ export class WidgetStore {
1873
1987
  // Anything held back for the draft now has a home (or goes to unread).
1874
1988
  this.replayDraftEvents();
1875
1989
  }
1990
+ // Waits for every send to settle, so this runs when the last one does.
1991
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1876
1992
  }
1877
1993
  }
1878
1994
  async identifyVisitor(data) {
@@ -2346,11 +2462,6 @@ function createConversationCreation(generation) {
2346
2462
  };
2347
2463
  }
2348
2464
 
2349
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2350
- function isLocalMessage(message) {
2351
- return message.id.startsWith('temp-');
2352
- }
2353
-
2354
2465
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2355
2466
  function adoptServerStatuses(messages, server) {
2356
2467
  const seen = new Set(server.filter(message => message.status === 'read').map(message => message.id));
@@ -2404,17 +2515,82 @@ function adoptServerAttachments(messages, server) {
2404
2515
  let changed = false;
2405
2516
  const next = messages.map(message => {
2406
2517
  const copy = serverCopies.get(message.id);
2407
- const hasLocal = message.attachments?.some(attachment => !/^https?:\/\//i.test(attachment.url));
2408
- 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;
2409
2521
  changed = true;
2410
2522
  return {
2411
2523
  ...message,
2412
- attachments: copy.attachments
2524
+ attachments
2413
2525
  };
2414
2526
  });
2415
2527
  return changed ? next : messages;
2416
2528
  }
2417
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
+
2418
2594
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2419
2595
  function sortByCreatedAt(messages) {
2420
2596
  const time = message => Date.parse(message.createdAt) || 0;