@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.
- package/CHANGELOG.md +47 -0
- package/README.md +8 -4
- package/lib/commonjs/api/client.js +40 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +80 -23
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/i18n.js +2 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +1 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +216 -40
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +7 -3
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +3 -2
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +36 -14
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +12 -2
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.js.map +1 -1
- package/lib/module/api/client.js +40 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +80 -23
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/i18n.js +2 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +1 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +216 -40
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +7 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +3 -2
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/icons.js +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +38 -16
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +12 -2
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +27 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +1 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +27 -3
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +17 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +10 -2
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +27 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +22 -10
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +1 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +27 -3
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +17 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +10 -2
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/client.ts +66 -2
- package/src/attachments/pickers.ts +87 -25
- package/src/i18n.ts +2 -0
- package/src/locales.ts +1 -0
- package/src/state/widget-store.ts +210 -33
- package/src/types.ts +17 -0
- package/src/ui/WidgetProvider.tsx +15 -4
- package/src/ui/components/ImageViewer.tsx +10 -6
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/screens/chat/ChatView.styles.ts +12 -2
- package/src/ui/screens/chat/ChatView.tsx +45 -22
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
- package/src/ui/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Visitor attachments: picking photos/files
|
|
3
|
-
*
|
|
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,
|
|
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,<…>`
|
|
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
|
|
174
|
-
const
|
|
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 =
|
|
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,
|
|
228
|
-
// (full-resolution)
|
|
229
|
-
base64
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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(
|
|
1299
|
-
: [...
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
1777
|
-
const hasServerMessages = local.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
|
|
1843
|
-
* attachments is removed instead — the
|
|
1844
|
-
* keeping both would leave a duplicate
|
|
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
|
-
|
|
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
|
-
|
|
2518
|
-
|
|
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
|
-
|
|
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;
|