@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
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Visitor attachments: picking photos/files and turning them into the base64
3
- * data URLs `POST /api/widget/messages` accepts.
2
+ * Visitor attachments: picking photos/files for `POST /api/widget/messages`.
3
+ *
4
+ * A server that takes uploads names its cap (`maxAttachmentBytes`): a picked
5
+ * file then stays a local URI and is uploaded when the message is sent. An
6
+ * older server only takes files inline, as base64 data URLs within the
7
+ * 1.5 MB / 6 MB caps below.
4
8
  *
5
9
  * The SDK ships no native code, so every picker is borrowed from the host —
6
10
  * the same approach as `storage.ts` and `native-gradient.ts`:
@@ -8,7 +12,7 @@
8
12
  * - `expo-document-picker` → any file
9
13
  * - `expo-file-system` → reads a picked file as base64 (optional; without it
10
14
  * the file is read through `fetch` + `FileReader`)
11
- * - `expo-image-manipulator` → shrinks photos to fit the 1.5 MB cap (optional;
15
+ * - `expo-image-manipulator` → shrinks photos, to fit the 1.5 MB inline cap (optional;
12
16
  * without it a full-resolution camera photo is usually refused as too large)
13
17
  *
14
18
  * A host without those (or with `react-native-image-picker` etc.) plugs its own
@@ -23,10 +27,10 @@ import type { AttachmentSource, RepzoPickedFile, RepzoWidgetAttachmentOptions }
23
27
 
24
28
  /** The server keeps the first five and silently drops the rest. */
25
29
  export const MAX_ATTACHMENTS = 5;
26
- /** Per file, decoded. The server silently drops anything larger. */
30
+ /** Per inline file, decoded. The server silently drops anything larger. */
27
31
  export const MAX_ATTACHMENT_BYTES = 1_500_000;
28
32
  /**
29
- * All files in one message, decoded. Base64 inside JSON inflates bytes by
33
+ * All inline files in one message, decoded. Base64 inside JSON inflates bytes by
30
34
  * ~1.37x and the server caps a request body at 10 MB, so five files at the
31
35
  * per-file maximum would come back as a 413.
32
36
  */
@@ -35,15 +39,17 @@ export const MAX_TOTAL_ATTACHMENT_BYTES = 6_000_000;
35
39
  /** Photos are re-encoded at this JPEG quality — small enough to fit the cap more often. */
36
40
  const IMAGE_QUALITY = 0.7;
37
41
 
38
- /** A picked file, encoded and ready to send; what the composer shows as a chip. */
42
+ /** A picked file, ready to send; what the composer shows as a chip. */
39
43
  export interface DraftAttachment {
40
44
  id: string;
41
45
  name: string;
42
46
  contentType: string;
43
47
  /** Decoded bytes — what the server measures against its cap. */
44
48
  size: number;
45
- /** `data:<mime>;base64,<…>` — the wire format. */
49
+ /** `data:<mime>;base64,<…>` for an inline file; empty when `fileUri` is uploaded instead. */
46
50
  dataUrl: string;
51
+ /** The local file uploaded when the message is sent. */
52
+ fileUri?: string;
47
53
  /** What `<Image>` renders locally: the picked file's URI, else the data URL. */
48
54
  previewUri: string;
49
55
  }
@@ -139,11 +145,13 @@ const CAMERA_DENIED = 'cameraDenied' as const;
139
145
 
140
146
  /**
141
147
  * Opens the picker for `source` and returns what fits alongside `current`.
148
+ * `uploadLimit` is the server's upload cap; without it files go inline.
142
149
  * Never throws: a cancelled pick is `{ added: [], problem: null }`.
143
150
  */
144
151
  export async function pickAttachments(
145
152
  source: AttachmentSource,
146
- current: DraftAttachment[]
153
+ current: DraftAttachment[],
154
+ uploadLimit: number | null = null
147
155
  ): Promise<PickResult> {
148
156
  const slots = MAX_ATTACHMENTS - current.length;
149
157
  if (slots <= 0) return { added: [], problem: 'tooMany' };
@@ -152,7 +160,7 @@ export async function pickAttachments(
152
160
  try {
153
161
  picked = hostOptions?.pick
154
162
  ? await hostOptions.pick(source)
155
- : await launchBuiltInPicker(source, slots);
163
+ : await launchBuiltInPicker(source, slots, !!uploadLimit);
156
164
  } catch (error) {
157
165
  console.warn('[repzo-widget] attachment picker failed', error);
158
166
  return { added: [], problem: 'failed' };
@@ -170,8 +178,9 @@ export async function pickAttachments(
170
178
  }
171
179
  // A size the picker already knows rules a file out before it's read, so
172
180
  // a 40 MB video never lands in memory as a base64 string.
173
- const declared = file.base64 ? null : validSize(file.size);
174
- const early = declared === null ? null : checkAttachmentFits(accepted, declared);
181
+ const uploads = !!uploadLimit && !!file.uri;
182
+ const declared = file.base64 && !uploads ? null : validSize(file.size);
183
+ const early = declared === null ? null : checkAttachmentFits(accepted, declared, uploads ? uploadLimit : null);
175
184
  if (early) {
176
185
  problem = problem ?? early;
177
186
  continue;
@@ -179,7 +188,7 @@ export async function pickAttachments(
179
188
 
180
189
  let draft: DraftAttachment;
181
190
  try {
182
- draft = await toDraft(file);
191
+ draft = uploads ? await toUploadDraft(file, uploadLimit!) : await toDraft(file);
183
192
  } catch (error) {
184
193
  if (error === TOO_LARGE) {
185
194
  problem = problem ?? 'tooLarge';
@@ -189,7 +198,8 @@ export async function pickAttachments(
189
198
  problem = problem ?? 'failed';
190
199
  continue;
191
200
  }
192
- const fits = draft.size > 0 ? checkAttachmentFits(accepted, draft.size) : 'failed';
201
+ const fits =
202
+ draft.size > 0 ? checkAttachmentFits(accepted, draft.size, uploads ? uploadLimit : null) : 'failed';
193
203
  if (fits) {
194
204
  problem = problem ?? fits;
195
205
  continue;
@@ -201,7 +211,8 @@ export async function pickAttachments(
201
211
 
202
212
  async function launchBuiltInPicker(
203
213
  source: AttachmentSource,
204
- slots: number
214
+ slots: number,
215
+ uploads: boolean
205
216
  ): Promise<RepzoPickedFile[] | null | typeof CAMERA_DENIED> {
206
217
  if (source === 'file') {
207
218
  if (!documentPicker) return null;
@@ -224,9 +235,10 @@ async function launchBuiltInPicker(
224
235
  if (!imagePicker) return null;
225
236
  const options = {
226
237
  quality: IMAGE_QUALITY,
227
- // With the manipulator the photo is re-encoded below, so the picker's own
228
- // (full-resolution) base64 would only waste memory.
229
- base64: !imageManipulator,
238
+ // With the manipulator the photo is re-encoded below, and an uploaded
239
+ // photo is read from its file, so the picker's own (full-resolution)
240
+ // base64 would only waste memory.
241
+ base64: !uploads && !imageManipulator,
230
242
  exif: false,
231
243
  };
232
244
 
@@ -246,7 +258,7 @@ async function launchBuiltInPicker(
246
258
  const assets: any[] = Array.isArray(result?.assets) ? result.assets : [];
247
259
  const files: RepzoPickedFile[] = [];
248
260
  for (const [index, asset] of assets.slice(0, slots).entries()) {
249
- const shrunk = await shrinkPhoto(asset);
261
+ const shrunk = await shrinkPhoto(asset, !uploads);
250
262
  const type = shrunk
251
263
  ? 'image/jpeg'
252
264
  : asset.mimeType || guessContentType(asset.fileName || asset.uri) || 'image/jpeg';
@@ -254,7 +266,7 @@ async function launchBuiltInPicker(
254
266
  files.push({
255
267
  name: `${baseName}.${extensionFor(type)}`,
256
268
  type,
257
- // No `size`: `fileSize` is the original's, not the re-encoded base64's.
269
+ // No `size`: `fileSize` is the original's, not the re-encoded file's.
258
270
  uri: shrunk?.uri ?? asset.uri,
259
271
  base64: shrunk?.base64 ?? asset.base64 ?? undefined,
260
272
  });
@@ -282,7 +294,10 @@ async function launchImagePicker(launch: (options: object) => Promise<any>, opti
282
294
  * MAX_IMAGE_EDGE. Null when the manipulator isn't installed or fails — the
283
295
  * caller then falls back to the picker's own output (and the size check).
284
296
  */
285
- async function shrinkPhoto(asset: any): Promise<{ uri: string; base64: string } | null> {
297
+ async function shrinkPhoto(
298
+ asset: any,
299
+ withBase64: boolean
300
+ ): Promise<{ uri: string; base64?: string } | null> {
286
301
  if (!imageManipulator || !asset?.uri) return null;
287
302
  const width = Number(asset.width) || 0;
288
303
  const height = Number(asset.height) || 0;
@@ -296,7 +311,7 @@ async function shrinkPhoto(asset: any): Promise<{ uri: string; base64: string }
296
311
  const saveOptions = {
297
312
  compress: IMAGE_QUALITY,
298
313
  format: imageManipulator.SaveFormat?.JPEG ?? 'jpeg',
299
- base64: true,
314
+ base64: withBase64,
300
315
  };
301
316
  try {
302
317
  let output: any;
@@ -308,6 +323,7 @@ async function shrinkPhoto(asset: any): Promise<{ uri: string; base64: string }
308
323
  const image = await context.renderAsync();
309
324
  output = await image.saveAsync(saveOptions);
310
325
  }
326
+ if (!withBase64) return typeof output?.uri === 'string' && output.uri ? { uri: output.uri } : null;
311
327
  return typeof output?.base64 === 'string' && output.base64
312
328
  ? { uri: output.uri ?? asset.uri, base64: output.base64 }
313
329
  : null;
@@ -340,6 +356,46 @@ async function toDraft(file: RepzoPickedFile): Promise<DraftAttachment> {
340
356
  };
341
357
  }
342
358
 
359
+ /** A file that's uploaded when the message is sent: only its size is read here. */
360
+ async function toUploadDraft(file: RepzoPickedFile, uploadLimit: number): Promise<DraftAttachment> {
361
+ const uri = file.uri!;
362
+ const size = validSize(file.size) ?? (await readFileSize(uri));
363
+ if (size > uploadLimit) throw TOO_LARGE;
364
+ const name = (file.name || fileNameFromUri(uri) || 'file').slice(0, 255);
365
+ return {
366
+ id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
367
+ name,
368
+ contentType: normalizeContentType(file.type) ?? guessContentType(name) ?? 'application/octet-stream',
369
+ size,
370
+ dataUrl: '',
371
+ fileUri: uri,
372
+ previewUri: uri,
373
+ };
374
+ }
375
+
376
+ /** A local file's size in bytes, or 0 when nothing can tell. */
377
+ async function readFileSize(uri: string): Promise<number> {
378
+ if (typeof fileSystem?.File === 'function') {
379
+ try {
380
+ const size = validSize(new fileSystem.File(uri).size);
381
+ if (size !== null) return size;
382
+ } catch {
383
+ // Fall through to the next reader.
384
+ }
385
+ }
386
+ if (typeof fileSystem?.getInfoAsync === 'function') {
387
+ try {
388
+ const size = validSize((await fileSystem.getInfoAsync(uri, { size: true }))?.size);
389
+ if (size !== null) return size;
390
+ } catch {
391
+ // Fall through to fetch.
392
+ }
393
+ }
394
+ // A native blob: its size is known without reading it into JS.
395
+ const blob = await (await fetch(uri)).blob();
396
+ return validSize(blob.size) ?? 0;
397
+ }
398
+
343
399
  /** Thrown (by identity) when a file is over the per-file cap before it's read. */
344
400
  const TOO_LARGE = new Error('File exceeds the attachment cap');
345
401
 
@@ -407,14 +463,20 @@ async function readBase64WithFetch(uri: string): Promise<string> {
407
463
 
408
464
  // ─── Pure helpers ──────────────────────────────────────────────────────────
409
465
 
410
- /** Null when a file of `size` bytes still fits beside `current`; otherwise the limit it breaks. */
466
+ /**
467
+ * Null when a file of `size` bytes still fits beside `current`; otherwise the
468
+ * limit it breaks. With `uploadLimit` the file is uploaded on its own, so
469
+ * only its own size counts; inline files also share one request body.
470
+ */
411
471
  export function checkAttachmentFits(
412
- current: ReadonlyArray<{ size: number }>,
413
- size: number
472
+ current: ReadonlyArray<{ size: number; fileUri?: string }>,
473
+ size: number,
474
+ uploadLimit: number | null = null
414
475
  ): AttachmentProblem | null {
415
476
  if (current.length >= MAX_ATTACHMENTS) return 'tooMany';
477
+ if (uploadLimit) return size > uploadLimit ? 'tooLarge' : null;
416
478
  if (size > MAX_ATTACHMENT_BYTES) return 'tooLarge';
417
- const total = current.reduce((sum, item) => sum + item.size, 0);
479
+ const total = current.filter((item) => !item.fileUri).reduce((sum, item) => sum + item.size, 0);
418
480
  if (total + size > MAX_TOTAL_ATTACHMENT_BYTES) return 'tooLargeTogether';
419
481
  return null;
420
482
  }
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.3.0';
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
 
@@ -235,6 +238,12 @@ export class WidgetStore {
235
238
  private seenMessageIds: string[] = [];
236
239
  /** Statuses (e.g. "read") that arrived for an id before a send response revealed it. */
237
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;
238
247
 
239
248
  /**
240
249
  * Identity generation. Bumped whenever boot/login/logout is requested; every
@@ -890,7 +899,9 @@ export class WidgetStore {
890
899
  this.threadGeneration += 1;
891
900
  this.conversationCreation = null;
892
901
  this.draftEvents = [];
902
+ this.failedActions.clear();
893
903
  this.cancelMarkRead();
904
+ this.cancelServerCopySync();
894
905
  }
895
906
 
896
907
  private requireBootConfig() {
@@ -1244,6 +1255,7 @@ export class WidgetStore {
1244
1255
  metadata: this.hydrateMetadata(data?.metadata || null),
1245
1256
  status: 'delivered',
1246
1257
  createdAt: data?.createdAt || new Date().toISOString(),
1258
+ ...(typeof data?.clientMessageId === 'string' ? { clientMessageId: data.clientMessageId } : {}),
1247
1259
  };
1248
1260
 
1249
1261
  const clearsTyping = message.direction === 'outbound' && message.senderType !== 'visitor';
@@ -1291,12 +1303,13 @@ export class WidgetStore {
1291
1303
  : current.conversations;
1292
1304
 
1293
1305
  if (onScreen) {
1294
- 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);
1295
1308
  return {
1296
1309
  conversations,
1297
1310
  messages: alreadyPresent
1298
- ? adoptServerAttachments(current.messages, [message])
1299
- : [...current.messages, message],
1311
+ ? adoptServerAttachments(shown, [message])
1312
+ : [...shown, message],
1300
1313
  agentTyping: clearsTyping ? false : current.agentTyping,
1301
1314
  };
1302
1315
  }
@@ -1697,11 +1710,12 @@ export class WidgetStore {
1697
1710
  this.setState((current) => {
1698
1711
  // Live replies and optimistic sends may have arrived since this page
1699
1712
  // was requested. Keep them, preferring the live copy for shared ids.
1700
- const liveIds = new Set(current.messages.map((message) => message.id));
1701
1713
  const page = this.hydrateMessages(result.data);
1714
+ const live = adoptOwnServerRows(current.messages, page);
1715
+ const liveIds = new Set(live.map((message) => message.id));
1702
1716
  const history = page.filter((message) => !liveIds.has(message.id));
1703
1717
  return {
1704
- messages: sortByCreatedAt([...history, ...adoptServerAttachments(current.messages, page)]),
1718
+ messages: sortByCreatedAt([...history, ...adoptServerAttachments(live, page)]),
1705
1719
  messagesLoading: false,
1706
1720
  hasMoreMessages: result.meta.hasMore,
1707
1721
  messageCursor: result.meta.nextCursor,
@@ -1765,16 +1779,17 @@ export class WidgetStore {
1765
1779
 
1766
1780
  const fresh = this.hydrateMessages(result.data);
1767
1781
  this.setState((current) => {
1768
- 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));
1769
1784
  const missing = fresh.filter((message) => !known.has(message.id));
1770
1785
  const upgraded = adoptServerStatuses(
1771
- adoptServerDeletions(adoptServerAttachments(current.messages, fresh), fresh),
1786
+ adoptServerDeletions(adoptServerAttachments(own, fresh), fresh),
1772
1787
  fresh
1773
1788
  );
1774
1789
  if (missing.length === 0) return upgraded === current.messages ? {} : { messages: upgraded };
1775
1790
 
1776
- const local = current.messages.filter(isLocalMessage);
1777
- const hasServerMessages = local.length < current.messages.length;
1791
+ const local = own.filter(isLocalMessage);
1792
+ const hasServerMessages = local.length < own.length;
1778
1793
  // No overlap means the gap is longer than a page: restart from the
1779
1794
  // newest page (keeping unsent bubbles) rather than leave a hole.
1780
1795
  if (missing.length === fresh.length || !hasServerMessages) {
@@ -1795,6 +1810,42 @@ export class WidgetStore {
1795
1810
  }
1796
1811
  }
1797
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
+
1798
1849
  /** A history page (newest-first) as the oldest-first list the UI renders. */
1799
1850
  private hydrateMessages(page: Message[]): Message[] {
1800
1851
  return page
@@ -1839,9 +1890,11 @@ export class WidgetStore {
1839
1890
 
1840
1891
  /**
1841
1892
  * Rejects when the message wasn't sent, so the composer can keep
1842
- * attachments for a retry. A text-only bubble is marked failed; one with
1843
- * attachments is removed instead — the composer's chips are the retry, and
1844
- * 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.
1845
1898
  */
1846
1899
  async sendMessage(
1847
1900
  body: string,
@@ -1852,7 +1905,6 @@ export class WidgetStore {
1852
1905
  if (!trimmed && attachments.length === 0) return;
1853
1906
 
1854
1907
  const { visitorProfile } = this.state;
1855
- const generation = this.threadGeneration;
1856
1908
  const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1857
1909
  const optimistic: Message = {
1858
1910
  id: tempId,
@@ -1883,8 +1935,17 @@ export class WidgetStore {
1883
1935
  createdAt: new Date().toISOString(),
1884
1936
  };
1885
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
+
1886
1947
  this.setState((current) => ({
1887
- messages: [...current.messages, optimistic],
1948
+ messages: [...current.messages.filter((message) => !superseded.includes(message.id)), optimistic],
1888
1949
  prefillText: '',
1889
1950
  }));
1890
1951
  this.track('widget_message_sent', {
@@ -1892,7 +1953,40 @@ export class WidgetStore {
1892
1953
  attachmentCount: attachments.length,
1893
1954
  });
1894
1955
 
1895
- 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);
1896
1990
  this.setState((current) => ({
1897
1991
  messages:
1898
1992
  attachments.length > 0
@@ -1901,6 +1995,7 @@ export class WidgetStore {
1901
1995
  message.id === tempId ? { ...message, status: 'failed' } : message
1902
1996
  ),
1903
1997
  }));
1998
+ };
1904
1999
 
1905
2000
  const epoch = this.epoch;
1906
2001
 
@@ -1928,22 +2023,31 @@ export class WidgetStore {
1928
2023
 
1929
2024
  let createdId: string | null = null;
1930
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;
1931
2040
  const result = await this.client.sendMessage({
1932
2041
  conversationId: conversationId || undefined,
1933
2042
  forceNewConversation: !conversationId,
1934
2043
  body: trimmed,
1935
- attachments:
1936
- attachments.length > 0
1937
- ? attachments.map(({ name, url, contentType, size }) => ({
1938
- name,
1939
- url,
1940
- contentType,
1941
- size,
1942
- }))
1943
- : undefined,
2044
+ attachments: sentAttachments.length > 0 ? sentAttachments : undefined,
1944
2045
  visitorName: visitorProfile.name,
1945
2046
  visitorEmail: visitorProfile.email,
1946
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,
1947
2051
  });
1948
2052
  if (this.isStale(epoch)) return;
1949
2053
  if (ownCreation) createdId = result.conversationId;
@@ -1988,8 +2092,15 @@ export class WidgetStore {
1988
2092
  : {}),
1989
2093
  };
1990
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;
1991
2099
  } catch (error) {
1992
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;
1993
2104
  markFailed();
1994
2105
  throw error;
1995
2106
  } finally {
@@ -2000,6 +2111,8 @@ export class WidgetStore {
2000
2111
  // Anything held back for the draft now has a home (or goes to unread).
2001
2112
  this.replayDraftEvents();
2002
2113
  }
2114
+ // Waits for every send to settle, so this runs when the last one does.
2115
+ if (!this.isStale(epoch)) void this.syncServerCopies();
2003
2116
  }
2004
2117
  }
2005
2118
 
@@ -2462,11 +2575,6 @@ function createConversationCreation(generation: number): ConversationCreation {
2462
2575
  return { generation, promise, resolve };
2463
2576
  }
2464
2577
 
2465
- /** Optimistic visitor bubbles the server hasn't acknowledged (pending/failed). */
2466
- function isLocalMessage(message: Message): boolean {
2467
- return message.id.startsWith('temp-');
2468
- }
2469
-
2470
2578
  /** A "Seen" missed while the stream was down shows up on the next re-read. */
2471
2579
  function adoptServerStatuses(messages: Message[], server: Message[]): Message[] {
2472
2580
  const seen = new Set(server.filter((message) => message.status === 'read').map((message) => message.id));
@@ -2514,14 +2622,83 @@ function adoptServerAttachments(messages: Message[], server: Message[]): Message
2514
2622
  let changed = false;
2515
2623
  const next = messages.map((message) => {
2516
2624
  const copy = serverCopies.get(message.id);
2517
- const hasLocal = message.attachments?.some((attachment) => !/^https?:\/\//i.test(attachment.url));
2518
- 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;
2628
+ changed = true;
2629
+ return { ...message, attachments };
2630
+ });
2631
+ return changed ? next : messages;
2632
+ }
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
+ }
2519
2673
  changed = true;
2520
- return { ...message, attachments: copy.attachments };
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
+ });
2521
2683
  });
2522
2684
  return changed ? next : messages;
2523
2685
  }
2524
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
+
2525
2702
  /** Stable, so same-instant rows keep their order; bad dates sort as oldest. */
2526
2703
  function sortByCreatedAt(messages: Message[]): Message[] {
2527
2704
  const time = (message: Message) => Date.parse(message.createdAt) || 0;