@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
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Voice notes (and any audio file) play inside the chat when the host app has
3
- * `expo-audio`; without it they stay a file row that opens in the browser.
2
+ * Voice notes (and any audio file) play inside the chat with the SDK's own
3
+ * native player (`src/audio/native-player.ts`). Where that isn't built into
4
+ * the app (Expo Go, bare React Native, an app built with SDK 1.2.2 or older),
5
+ * they play with the host app's `expo-audio` if it has one; otherwise they
6
+ * stay a file row that opens in the browser.
4
7
  *
5
8
  * Resolved once at module load, like the attachment pickers: each `require` is
6
9
  * static so Metro can see it, and with `allowOptionalDependencies` (see
@@ -8,6 +11,8 @@
8
11
  * `try` around it catches.
9
12
  */
10
13
 
14
+ import { hasNativeVoicePlayer, useNativeVoicePlayer } from './native-player';
15
+
11
16
  let expoAudio: any = null;
12
17
  try {
13
18
  const mod = require('expo-audio');
@@ -31,9 +36,11 @@ try {
31
36
  /** How often the playhead moves while a note plays. */
32
37
  const UPDATE_INTERVAL_MS = 100;
33
38
 
34
- /** The installed expo-audio module, or null when the app doesn't have it. */
35
- export function voicePlaybackModule(): any {
36
- return expoAudio;
39
+ /** Which player voice notes use in this app, or null when audio stays a file row. */
40
+ const backend: 'native' | 'expo-audio' | null = hasNativeVoicePlayer() ? 'native' : expoAudio ? 'expo-audio' : null;
41
+
42
+ export function voicePlaybackBackend(): 'native' | 'expo-audio' | null {
43
+ return backend;
37
44
  }
38
45
 
39
46
  /** The second argument to `useAudioPlayer` for the installed expo-audio. */
@@ -41,9 +48,26 @@ export function audioPlayerOptions(): unknown {
41
48
  return takesOptionsObject ? { updateInterval: UPDATE_INTERVAL_MS } : UPDATE_INTERVAL_MS;
42
49
  }
43
50
 
51
+ /** expo-audio's player for a note, on the same terms as the native one. */
52
+ function useExpoAudioPlayer(url: string): { player: any; status: any } {
53
+ const player = expoAudio.useAudioPlayer({ uri: url }, audioPlayerOptions());
54
+ const status = expoAudio.useAudioPlayerStatus(player);
55
+ return { player, status };
56
+ }
57
+
58
+ /**
59
+ * The player for one note: `play()`, `pause()`, `seekTo(seconds)`,
60
+ * `setPlaybackRate(rate)`, and its status (`currentTime`, `duration`,
61
+ * `playing`, `isLoaded`, `isBuffering`, `didJustFinish`, `error`). Only use it
62
+ * when `voicePlaybackBackend()` isn't null. Picked once, since the backend
63
+ * never changes while the app runs.
64
+ */
65
+ export const useVoicePlayer: (url: string) => { player: any; status: any } =
66
+ backend === 'native' ? useNativeVoicePlayer : useExpoAudioPlayer;
67
+
44
68
  /** Audio the chat can play in place: an `audio/*` attachment with a web address. */
45
69
  export function isPlayableAudio(contentType: string | null | undefined, url: string | null | undefined): boolean {
46
- return !!expoAudio && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
70
+ return backend != null && (contentType ?? '').toLowerCase().startsWith('audio/') && /^https?:\/\//i.test(url ?? '');
47
71
  }
48
72
 
49
73
  // ─── One note at a time ─────────────────────────────────────────────────────
package/src/i18n.ts CHANGED
@@ -82,6 +82,8 @@ export const DEFAULT_LABELS = {
82
82
  cameraPermissionDenied: 'Allow camera access in Settings to take a photo',
83
83
  cancel: 'Cancel',
84
84
  failedToSend: 'Failed to send',
85
+ // After "Failed to send" under a message the visitor can send again.
86
+ tapToRetry: 'Tap to retry',
85
87
  thinking: 'Typing',
86
88
  messages: 'Messages',
87
89
  noMessages: 'No messages yet',
package/src/locales.ts CHANGED
@@ -38,6 +38,7 @@ const ar: LabelMap = {
38
38
  cameraPermissionDenied: 'اسمح بالوصول إلى الكاميرا من الإعدادات لالتقاط صورة',
39
39
  cancel: 'إلغاء',
40
40
  failedToSend: 'تعذر الإرسال',
41
+ tapToRetry: 'اضغط لإعادة المحاولة',
41
42
  thinking: 'يكتب',
42
43
  messages: 'الرسائل',
43
44
  noMessages: 'لا توجد رسائل بعد',
@@ -37,10 +37,11 @@ import type {
37
37
  WidgetUser,
38
38
  WidgetView,
39
39
  } from '../types';
40
+ import { canRetryMessage, isLocalMessage } from '../ui/utils/conversations';
40
41
  import { setOpenUrlHandler } from '../ui/utils/safe-url';
41
42
 
42
43
  /** Sent on every init; must match `package.json` (tests/version.test.js checks). */
43
- const SDK_VERSION = '@repzo/widget-react-native@1.2.2';
44
+ const SDK_VERSION = '@repzo/widget-react-native@1.3.1';
44
45
 
45
46
  /** The platform this session reports — mobile sessions get the Mobile SDKs overrides. */
46
47
  function sessionPlatform(): 'ios' | 'android' | 'web' {
@@ -76,6 +77,8 @@ const MARK_READ_DEBOUNCE_MS = 1_000;
76
77
  const FOREGROUND_REFRESH_AFTER_MS = 5 * 60_000;
77
78
  /** Live events held back while a draft's first send is creating its thread. */
78
79
  const DRAFT_EVENT_BUFFER_LIMIT = 50;
80
+ /** Waits between re-reads for a sent file's stored copy while Workstation is still storing it. */
81
+ const SERVER_COPY_RETRY_MS = [2_000, 5_000, 15_000];
79
82
  /** How long a post stays up after a reaction, thanking them, before it goes. */
80
83
  export const POST_THANKS_MS = 1600;
81
84
 
@@ -216,6 +219,13 @@ export class WidgetStore {
216
219
 
217
220
  private pendingConversationId: string | null = null;
218
221
 
222
+ /**
223
+ * The screen a thread was opened from, so its back button returns there.
224
+ * Null when it was opened with the messenger closed (a push, a notification
225
+ * card, `presentConversation`): there is nothing to go back to.
226
+ */
227
+ private chatOpenedFrom: 'home' | 'conversations' | null = null;
228
+
219
229
  private appStateSubscription: { remove(): void } | null = null;
220
230
 
221
231
  /** When the app last went to the background (0: hasn't this session). */
@@ -228,6 +238,12 @@ export class WidgetStore {
228
238
  private seenMessageIds: string[] = [];
229
239
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
230
240
  private earlyStatuses = new Map<string, string>();
241
+ /** The agent UI action behind a failed bubble, so Retry runs it again too. */
242
+ private failedActions = new Map<string, AgentUiAction>();
243
+ /** The next re-read for a sent file's stored copy (see `syncServerCopies`). */
244
+ private serverCopyTimer: ReturnType<typeof setTimeout> | null = null;
245
+ private serverCopyRetries = 0;
246
+ private serverCopySyncing = false;
231
247
 
232
248
  /**
233
249
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -321,6 +337,14 @@ export class WidgetStore {
321
337
  }
322
338
  this.state = next;
323
339
 
340
+ // Recorded here because a thread opens from many places (home cards, the
341
+ // list, the Messages tab, posts) and each would otherwise have to say so.
342
+ const intoThread = next.view === 'chat' || next.view === 'prechat';
343
+ if (intoThread && previous.view !== 'chat' && previous.view !== 'prechat') {
344
+ this.chatOpenedFrom =
345
+ previous.isOpen && (previous.view === 'home' || previous.view === 'conversations') ? previous.view : null;
346
+ }
347
+
324
348
  if (next.unreadCount !== previous.unreadCount) {
325
349
  this.events.emit(RepzoWidgetEvents.UnreadCountDidChange, { count: next.unreadCount });
326
350
  }
@@ -875,7 +899,9 @@ export class WidgetStore {
875
899
  this.threadGeneration += 1;
876
900
  this.conversationCreation = null;
877
901
  this.draftEvents = [];
902
+ this.failedActions.clear();
878
903
  this.cancelMarkRead();
904
+ this.cancelServerCopySync();
879
905
  }
880
906
 
881
907
  private requireBootConfig() {
@@ -1229,6 +1255,7 @@ export class WidgetStore {
1229
1255
  metadata: this.hydrateMetadata(data?.metadata || null),
1230
1256
  status: 'delivered',
1231
1257
  createdAt: data?.createdAt || new Date().toISOString(),
1258
+ ...(typeof data?.clientMessageId === 'string' ? { clientMessageId: data.clientMessageId } : {}),
1232
1259
  };
1233
1260
 
1234
1261
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
@@ -1276,12 +1303,13 @@ export class WidgetStore {
1276
1303
  : current.conversations;
1277
1304
 
1278
1305
  if (onScreen) {
1279
- const alreadyPresent = current.messages.some((existing) => existing.id === message.id);
1306
+ const shown = adoptOwnServerRows(current.messages, [message]);
1307
+ const alreadyPresent = shown.some((existing) => existing.id === message.id);
1280
1308
  return {
1281
1309
  conversations,
1282
1310
  messages: alreadyPresent
1283
- ? adoptServerAttachments(current.messages, [message])
1284
- : [...current.messages, message],
1311
+ ? adoptServerAttachments(shown, [message])
1312
+ : [...shown, message],
1285
1313
  agentTyping: clearsTyping ? false : current.agentTyping,
1286
1314
  };
1287
1315
  }
@@ -1524,6 +1552,14 @@ export class WidgetStore {
1524
1552
  });
1525
1553
  }
1526
1554
 
1555
+ /**
1556
+ * The thread's back button: the screen it was opened from, or the
1557
+ * conversation list when it was opened from outside the messenger.
1558
+ */
1559
+ leaveConversation(): void {
1560
+ this.setView(this.chatOpenedFrom ?? (this.state.activeTab === 'messages' ? 'conversations' : 'home'));
1561
+ }
1562
+
1527
1563
  setActiveTab(tab: WidgetSpaceType): void {
1528
1564
  if (tab === 'home') {
1529
1565
  this.setState({ activeTab: 'home', view: 'home' });
@@ -1674,11 +1710,12 @@ export class WidgetStore {
1674
1710
  this.setState((current) => {
1675
1711
  // Live replies and optimistic sends may have arrived since this page
1676
1712
  // was requested. Keep them, preferring the live copy for shared ids.
1677
- const liveIds = new Set(current.messages.map((message) => message.id));
1678
1713
  const page = this.hydrateMessages(result.data);
1714
+ const live = adoptOwnServerRows(current.messages, page);
1715
+ const liveIds = new Set(live.map((message) => message.id));
1679
1716
  const history = page.filter((message) => !liveIds.has(message.id));
1680
1717
  return {
1681
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1718
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1682
1719
  messagesLoading: false,
1683
1720
  hasMoreMessages: result.meta.hasMore,
1684
1721
  messageCursor: result.meta.nextCursor,
@@ -1742,16 +1779,17 @@ export class WidgetStore {
1742
1779
 
1743
1780
  const fresh = this.hydrateMessages(result.data);
1744
1781
  this.setState((current) => {
1745
- const known = new Set(current.messages.map((message) => message.id));
1782
+ const own = adoptOwnServerRows(current.messages, fresh);
1783
+ const known = new Set(own.map((message) => message.id));
1746
1784
  const missing = fresh.filter((message) => !known.has(message.id));
1747
1785
  const upgraded = adoptServerStatuses(
1748
- adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh),
1786
+ adoptServerDeletions(adoptServerAttachments(own, fresh), fresh),
1749
1787
  fresh
1750
1788
  );
1751
1789
  if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
1752
1790
 
1753
- const local = current.messages.filter(isLocalMessage);
1754
- const hasServerMessages = local.length < current.messages.length;
1791
+ const local = own.filter(isLocalMessage);
1792
+ const hasServerMessages = local.length < own.length;
1755
1793
  // No overlap means the gap is longer than a page: restart from the
1756
1794
  // newest page (keeping unsent bubbles) rather than leave a hole.
1757
1795
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1772,6 +1810,42 @@ export class WidgetStore {
1772
1810
  }
1773
1811
  }
1774
1812
 
1813
+ /**
1814
+ * Until its stored copy replaces it, a sent bubble points at the visitor's
1815
+ * local files: a photo still opens in the viewer, but a PDF has nothing to
1816
+ * open. When the stream's echo hasn't brought that copy, re-read the
1817
+ * thread, and try again a few times while Workstation is still storing it.
1818
+ */
1819
+ private async syncServerCopies(): Promise<void> {
1820
+ this.cancelServerCopySync();
1821
+ if (this.serverCopySyncing || !this.state.messages.some(isAwaitingServerCopy)) return;
1822
+ // A send still in flight would come back as a stored row beside its own
1823
+ // pending bubble. It syncs again when it settles.
1824
+ if (this.state.messages.some((message) => isLocalMessage(message) && message.status === 'pending')) return;
1825
+ // Off screen: the thread reloads in full when it's opened again.
1826
+ const conversationId = this.state.activeConversationId;
1827
+ if (!conversationId || !this.isThreadOnScreen(conversationId)) return;
1828
+
1829
+ const generation = this.threadGeneration;
1830
+ this.serverCopySyncing = true;
1831
+ try {
1832
+ // A no-op while a page load is in flight; the retry below covers it.
1833
+ await this.refreshOpenThread();
1834
+ } finally {
1835
+ this.serverCopySyncing = false;
1836
+ }
1837
+ if (this.threadGeneration !== generation || !this.state.messages.some(isAwaitingServerCopy)) return;
1838
+ const delay = SERVER_COPY_RETRY_MS[this.serverCopyRetries];
1839
+ if (delay === undefined) return;
1840
+ this.serverCopyRetries += 1;
1841
+ this.serverCopyTimer = setTimeout(() => void this.syncServerCopies(), delay);
1842
+ }
1843
+
1844
+ private cancelServerCopySync(): void {
1845
+ if (this.serverCopyTimer) clearTimeout(this.serverCopyTimer);
1846
+ this.serverCopyTimer = null;
1847
+ }
1848
+
1775
1849
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1776
1850
  private hydrateMessages(page: Message[]): Message[] {
1777
1851
  return page
@@ -1816,9 +1890,11 @@ export class WidgetStore {
1816
1890
 
1817
1891
  /**
1818
1892
  * Rejects when the message wasn't sent, so the composer can keep
1819
- * attachments for a retry. A text-only bubble is marked failed; one with
1820
- * attachments is removed instead — the composer's chips are the retry, and
1821
- * keeping both would leave a duplicate after a successful resend.
1893
+ * attachments for a retry. A text-only bubble is marked failed and can be
1894
+ * re-sent with `retryMessage`; one with attachments is removed instead — the
1895
+ * composer's chips are the retry, and keeping both would leave a duplicate
1896
+ * after a successful resend. Likewise an agent UI action replaces the
1897
+ * failed bubble of the same action, since its card stays tappable.
1822
1898
  */
1823
1899
  async sendMessage(
1824
1900
  body: string,
@@ -1829,7 +1905,6 @@ export class WidgetStore {
1829
1905
  if (!trimmed && attachments.length === 0) return;
1830
1906
 
1831
1907
  const { visitorProfile } = this.state;
1832
- const generation = this.threadGeneration;
1833
1908
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1834
1909
  const optimistic: Message = {
1835
1910
  id: tempId,
@@ -1860,8 +1935,17 @@ export class WidgetStore {
1860
1935
  createdAt: new Date().toISOString(),
1861
1936
  };
1862
1937
 
1938
+ // Tapping the card again is the retry: offering the old bubble's retry
1939
+ // too would run the action twice (two items in the cart, two bookings).
1940
+ const superseded = agentUiAction
1941
+ ? [...this.failedActions]
1942
+ .filter(([, failed]) => failed.id === agentUiAction.id && failed.type === agentUiAction.type)
1943
+ .map(([messageId]) => messageId)
1944
+ : [];
1945
+ superseded.forEach((messageId) => this.failedActions.delete(messageId));
1946
+
1863
1947
  this.setState((current) => ({
1864
- messages: [...current.messages, optimistic],
1948
+ messages: [...current.messages.filter((message) => !superseded.includes(message.id)), optimistic],
1865
1949
  prefillText: '',
1866
1950
  }));
1867
1951
  this.track('widget_message_sent', {
@@ -1869,7 +1953,40 @@ export class WidgetStore {
1869
1953
  attachmentCount: attachments.length,
1870
1954
  });
1871
1955
 
1872
- const markFailed = () =>
1956
+ return this.deliverMessage(tempId, trimmed, attachments, agentUiAction);
1957
+ }
1958
+
1959
+ /**
1960
+ * Re-sends a text bubble that failed. It moves to the end of the thread,
1961
+ * since it now goes out after anything sent meanwhile. Rejects like
1962
+ * `sendMessage` when it fails again; the bubble is then failed once more.
1963
+ */
1964
+ async retryMessage(messageId: string): Promise<void> {
1965
+ const failed = this.state.messages.find(
1966
+ (message) => message.id === messageId && canRetryMessage(message)
1967
+ );
1968
+ if (!failed) return;
1969
+ const agentUiAction = this.failedActions.get(messageId);
1970
+ this.failedActions.delete(messageId);
1971
+ const pending: Message = { ...failed, status: 'pending', createdAt: new Date().toISOString() };
1972
+ this.setState((current) => ({
1973
+ messages: [...current.messages.filter((message) => message.id !== messageId), pending],
1974
+ }));
1975
+ return this.deliverMessage(messageId, failed.bodyPlain ?? '', [], agentUiAction);
1976
+ }
1977
+
1978
+ /** Sends the optimistic bubble `tempId`, already on screen as pending. */
1979
+ private async deliverMessage(
1980
+ tempId: string,
1981
+ trimmed: string,
1982
+ attachments: OutgoingAttachment[],
1983
+ agentUiAction: AgentUiAction | undefined
1984
+ ): Promise<void> {
1985
+ const { visitorProfile } = this.state;
1986
+ const generation = this.threadGeneration;
1987
+
1988
+ const markFailed = () => {
1989
+ if (agentUiAction && attachments.length === 0) this.failedActions.set(tempId, agentUiAction);
1873
1990
  this.setState((current) => ({
1874
1991
  messages:
1875
1992
  attachments.length > 0
@@ -1878,6 +1995,7 @@ export class WidgetStore {
1878
1995
  message.id === tempId ? { ...message, status: 'failed' } : message
1879
1996
  ),
1880
1997
  }));
1998
+ };
1881
1999
 
1882
2000
  const epoch = this.epoch;
1883
2001
 
@@ -1905,22 +2023,31 @@ export class WidgetStore {
1905
2023
 
1906
2024
  let createdId: string | null = null;
1907
2025
  try {
2026
+ // Local files go up first; the message names each by its upload token.
2027
+ const sentAttachments = await Promise.all(
2028
+ attachments.map(async ({ name, url, fileUri, contentType, size }) =>
2029
+ fileUri
2030
+ ? {
2031
+ name,
2032
+ contentType,
2033
+ size,
2034
+ uploadToken: await this.client.uploadAttachment({ uri: fileUri, name, contentType, size }),
2035
+ }
2036
+ : { name, url, contentType, size }
2037
+ )
2038
+ );
2039
+ if (this.isStale(epoch)) return;
1908
2040
  const result = await this.client.sendMessage({
1909
2041
  conversationId: conversationId || undefined,
1910
2042
  forceNewConversation: !conversationId,
1911
2043
  body: trimmed,
1912
- attachments:
1913
- attachments.length > 0
1914
- ? attachments.map(({ name, url, contentType, size }) => ({
1915
- name,
1916
- url,
1917
- contentType,
1918
- size,
1919
- }))
1920
- : undefined,
2044
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1921
2045
  visitorName: visitorProfile.name,
1922
2046
  visitorEmail: visitorProfile.email,
1923
2047
  agentUiAction,
2048
+ // The bubble's id, the same on every retry: a newer Workstation stores
2049
+ // the message once even when an earlier attempt's response was lost.
2050
+ clientMessageId: tempId,
1924
2051
  });
1925
2052
  if (this.isStale(epoch)) return;
1926
2053
  if (ownCreation) createdId = result.conversationId;
@@ -1965,8 +2092,15 @@ export class WidgetStore {
1965
2092
  : {}),
1966
2093
  };
1967
2094
  });
2095
+
2096
+ // New files to fetch the stored copy of: a fresh round of re-reads.
2097
+ const sent = this.state.messages.find((message) => message.id === result.messageId);
2098
+ if (sent && hasLocalAttachments(sent)) this.serverCopyRetries = 0;
1968
2099
  } catch (error) {
1969
2100
  if (this.isStale(epoch)) throw error;
2101
+ // The response was lost, but its stored row has already taken the
2102
+ // bubble's place (newer Workstation deployments): it was sent.
2103
+ if (this.state.messages.some((message) => message.clientMessageId === tempId)) return;
1970
2104
  markFailed();
1971
2105
  throw error;
1972
2106
  } finally {
@@ -1977,6 +2111,8 @@ export class WidgetStore {
1977
2111
  // Anything held back for the draft now has a home (or goes to unread).
1978
2112
  this.replayDraftEvents();
1979
2113
  }
2114
+ // Waits for every send to settle, so this runs when the last one does.
2115
+ if (!this.isStale(epoch)) void this.syncServerCopies();
1980
2116
  }
1981
2117
  }
1982
2118
 
@@ -2439,11 +2575,6 @@ function createConversationCreation(generation: number): ConversationCreation {
2439
2575
  return { generation, promise, resolve };
2440
2576
  }
2441
2577
 
2442
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2443
- function isLocalMessage(message: Message): boolean {
2444
- return message.id.startsWith('temp-');
2445
- }
2446
-
2447
2578
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2448
2579
  function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
2449
2580
  const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
@@ -2491,14 +2622,83 @@ function adoptServerAttachments(messages: Message[], server: Message[]): Message
2491
2622
  let changed = false;
2492
2623
  const next = messages.map((message) => {
2493
2624
  const copy = serverCopies.get(message.id);
2494
- const hasLocal = message.attachments?.some((attachment) => !/^https?:\/\//i.test(attachment.url));
2495
- if (!copy || !hasLocal) return message;
2625
+ if (!copy || !hasLocalAttachments(message)) return message;
2626
+ const attachments = mergeAttachments(message.attachments!, copy.attachments!);
2627
+ if (attachments === message.attachments) return message;
2496
2628
  changed = true;
2497
- return { ...message, attachments: copy.attachments };
2629
+ return { ...message, attachments };
2498
2630
  });
2499
2631
  return changed ? next : messages;
2500
2632
  }
2501
2633
 
2634
+ /**
2635
+ * Each attachment's server copy, except one without a usable URL: that would
2636
+ * only blank its local preview, so the preview stays until a later copy has it.
2637
+ * Returns `local` itself when nothing changes.
2638
+ */
2639
+ function mergeAttachments(local: WidgetAttachment[], server: WidgetAttachment[]): WidgetAttachment[] {
2640
+ // Without a one-to-one match, only a fully usable server copy is safe to take.
2641
+ if (local.length !== server.length) {
2642
+ return server.every((attachment) => attachment.url) ? server : local;
2643
+ }
2644
+ const merged = server.map((attachment, index) => {
2645
+ const own = local[index]!;
2646
+ return isLocalAttachment(own) && attachment.url ? attachment : own;
2647
+ });
2648
+ return merged.every((attachment, index) => attachment === local[index]) ? local : merged;
2649
+ }
2650
+
2651
+ /**
2652
+ * Newer Workstation deployments echo `clientMessageId` on the visitor's own
2653
+ * rows. Such a row replaces the pending or failed bubble it came from instead
2654
+ * of showing beside it: no duplicate while a send is in flight, and no retry
2655
+ * for a message that was stored although its response was lost.
2656
+ */
2657
+ function adoptOwnServerRows(messages: Message[], server: Message[]): Message[] {
2658
+ const rows = new Map<string, Message>();
2659
+ server.forEach((row) => {
2660
+ if (typeof row.clientMessageId === 'string' && row.clientMessageId) rows.set(row.clientMessageId, row);
2661
+ });
2662
+ if (rows.size === 0) return messages;
2663
+
2664
+ const shown = new Set(messages.map((message) => message.id));
2665
+ let changed = false;
2666
+ const next: Message[] = [];
2667
+ messages.forEach((message) => {
2668
+ const row = isLocalMessage(message) ? rows.get(message.id) : undefined;
2669
+ if (!row) {
2670
+ next.push(message);
2671
+ return;
2672
+ }
2673
+ changed = true;
2674
+ // Already on screen under its server id (an earlier echo): drop the bubble.
2675
+ if (shown.has(row.id)) return;
2676
+ next.push({
2677
+ ...row,
2678
+ attachments:
2679
+ row.attachments && message.attachments
2680
+ ? mergeAttachments(message.attachments, row.attachments)
2681
+ : (row.attachments ?? message.attachments),
2682
+ });
2683
+ });
2684
+ return changed ? next : messages;
2685
+ }
2686
+
2687
+ /** Still the picked file (a `file://` URI, or no URL at all), not the uploaded copy. */
2688
+ function isLocalAttachment(attachment: WidgetAttachment): boolean {
2689
+ return !/^https?:\/\//i.test(attachment.url);
2690
+ }
2691
+
2692
+ /** A visitor bubble still showing the picked files rather than the uploaded copies. */
2693
+ function hasLocalAttachments(message: Message): boolean {
2694
+ return !!message.attachments?.some(isLocalAttachment);
2695
+ }
2696
+
2697
+ /** A sent bubble whose stored copy hasn't replaced the picked files yet. */
2698
+ function isAwaitingServerCopy(message: Message): boolean {
2699
+ return !isLocalMessage(message) && hasLocalAttachments(message);
2700
+ }
2701
+
2502
2702
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2503
2703
  function sortByCreatedAt(messages: Message[]): Message[] {
2504
2704
  const time = (message: Message) => Date.parse(message.createdAt) || 0;
package/src/types.ts CHANGED
@@ -64,6 +64,12 @@ export interface WidgetConfig {
64
64
  proactiveFrequency: 'session' | 'visitor';
65
65
  proactiveOpenMode: 'home' | 'new_message';
66
66
  attachmentsEnabled: boolean;
67
+ /**
68
+ * Largest file a visitor may attach. Set by servers that take uploads
69
+ * (`/api/widget/uploads`); absent on older ones, which only take files
70
+ * inline in the message.
71
+ */
72
+ maxAttachmentBytes?: number;
67
73
  analyticsEnabled: boolean;
68
74
  toursEnabled: boolean;
69
75
  defaultLocale: string;
@@ -94,7 +100,10 @@ export interface WidgetAttachment {
94
100
  */
95
101
  export interface OutgoingAttachment {
96
102
  name: string;
103
+ /** A base64 data URL; empty when `fileUri` is set. */
97
104
  url: string;
105
+ /** A local file uploaded before the message is sent (servers with `maxAttachmentBytes`). */
106
+ fileUri?: string;
98
107
  contentType: string;
99
108
  /** Decoded bytes. */
100
109
  size: number;
@@ -136,6 +145,14 @@ export interface Message {
136
145
  createdAt: string;
137
146
  /** Set when a teammate deleted the message; the content fields are then empty. */
138
147
  deletedAt?: string | null;
148
+ /**
149
+ * On the visitor's own messages: the id this device sent it under, which
150
+ * stays the same when a failed message is retried. Lets a stored message
151
+ * replace its pending or failed bubble. Only newer Workstation deployments
152
+ * send it.
153
+ * @since 1.3.1
154
+ */
155
+ clientMessageId?: string | null;
139
156
  }
140
157
 
141
158
  export interface OutboundMessage {
@@ -25,11 +25,12 @@ import {
25
25
  MAX_ATTACHMENT_BYTES,
26
26
  MAX_ATTACHMENTS,
27
27
  pickAttachments,
28
+ type DraftAttachment,
28
29
  } from '../attachments/pickers';
29
30
  import { controller } from '../controller';
30
31
  import { buildLabels, createTranslator, formatLabel, localizeWelcomeText } from '../i18n';
31
32
  import { useWidgetState } from '../state/use-widget-state';
32
- import type { AgentUiAction, OutgoingAttachment, PostInteractionKind, WidgetSpaceType } from '../types';
33
+ import type { AgentUiAction, AttachmentSource, OutgoingAttachment, PostInteractionKind, WidgetSpaceType } from '../types';
33
34
  import { WidgetErrorBoundary } from './components/ErrorBoundary';
34
35
  import { AlertIcon } from './components/icons';
35
36
  import { InAppNotification } from './components/InAppNotification';
@@ -163,13 +164,15 @@ export function RepzoWidgetProvider({
163
164
  file: labels.attachChooseFile,
164
165
  cancel: labels.cancel,
165
166
  remove: labels.removeAttachment,
166
- tooLarge: formatLabel(labels.attachmentTooLarge, { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
167
+ tooLarge: formatLabel(labels.attachmentTooLarge, {
168
+ max: formatBytes(theme.maxAttachmentBytes ?? MAX_ATTACHMENT_BYTES),
169
+ }),
167
170
  tooMany: formatLabel(labels.attachmentTooMany, { count: MAX_ATTACHMENTS }),
168
171
  tooLargeTogether: labels.attachmentsTooLargeTogether,
169
172
  failed: labels.attachmentFailed,
170
173
  cameraDenied: labels.cameraPermissionDenied,
171
174
  }),
172
- [labels]
175
+ [labels, theme.maxAttachmentBytes]
173
176
  );
174
177
 
175
178
  // Stable across renders so each message bubble only re-renders when its
@@ -182,6 +185,7 @@ export function RepzoWidgetProvider({
182
185
  emptyConversation: labels.emptyConversation,
183
186
  messagePlaceholder: labels.messagePlaceholder,
184
187
  failedToSend: labels.failedToSend,
188
+ tapToRetry: labels.tapToRetry,
185
189
  openAttachment: labels.openAttachment,
186
190
  thinking: labels.thinking,
187
191
  team: labels.team,
@@ -238,6 +242,11 @@ export function RepzoWidgetProvider({
238
242
  const attachmentSources = theme.attachmentsEnabled ? getAttachmentSources() : [];
239
243
 
240
244
  const handleClose = useCallback(() => store.hide(), [store]);
245
+ const handlePickAttachments = useCallback(
246
+ (source: AttachmentSource, current: DraftAttachment[]) =>
247
+ pickAttachments(source, current, theme.maxAttachmentBytes),
248
+ [theme.maxAttachmentBytes]
249
+ );
241
250
 
242
251
  const handleOpenNotification = useCallback(
243
252
  (conversationId: string) => void store.openConversation(conversationId),
@@ -304,6 +313,7 @@ export function RepzoWidgetProvider({
304
313
  store.sendMessage(body, attachments ?? [], action),
305
314
  [store]
306
315
  );
316
+ const handleRetryMessage = useCallback((messageId: string) => store.retryMessage(messageId), [store]);
307
317
 
308
318
  const handleIdentifyVisitor = useCallback(
309
319
  (data: { email?: string; name?: string; phone?: string }) => store.identifyVisitor(data),
@@ -332,9 +342,7 @@ export function RepzoWidgetProvider({
332
342
  [store]
333
343
  );
334
344
 
335
- const handleBack = useCallback(() => {
336
- store.setView(state.activeTab === 'messages' ? 'conversations' : 'home');
337
- }, [state.activeTab, store]);
345
+ const handleBack = useCallback(() => store.leaveConversation(), [store]);
338
346
 
339
347
  const handleStartConversation = useCallback(() => {
340
348
  if (!theme.allowNewConversations) return;
@@ -444,8 +452,9 @@ export function RepzoWidgetProvider({
444
452
  offlineMessage={theme.offlineMessage}
445
453
  labels={chatLabels}
446
454
  attachmentSources={attachmentSources}
447
- onPickAttachments={pickAttachments}
455
+ onPickAttachments={handlePickAttachments}
448
456
  onSendMessage={handleSendMessage}
457
+ onRetryMessage={handleRetryMessage}
449
458
  onIdentifyVisitor={handleIdentifyVisitor}
450
459
  onRateConversation={handleRateConversation}
451
460
  onLoadMore={handleLoadMore}