@repzo/widget-react-native 1.0.0 → 1.0.4

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 (153) hide show
  1. package/README.md +167 -74
  2. package/lib/commonjs/api/client.js +3 -1
  3. package/lib/commonjs/api/client.js.map +1 -1
  4. package/lib/commonjs/api/realtime.js +2 -0
  5. package/lib/commonjs/api/realtime.js.map +1 -1
  6. package/lib/commonjs/attachments/pickers.js +493 -0
  7. package/lib/commonjs/attachments/pickers.js.map +1 -0
  8. package/lib/commonjs/i18n.js +45 -0
  9. package/lib/commonjs/i18n.js.map +1 -1
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/locales.js +21 -0
  12. package/lib/commonjs/locales.js.map +1 -1
  13. package/lib/commonjs/state/widget-store.js +256 -18
  14. package/lib/commonjs/state/widget-store.js.map +1 -1
  15. package/lib/commonjs/types.js +16 -0
  16. package/lib/commonjs/types.js.map +1 -1
  17. package/lib/commonjs/ui/WidgetProvider.js +70 -10
  18. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  19. package/lib/commonjs/ui/components/AttachmentChips.js +135 -0
  20. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -0
  21. package/lib/commonjs/ui/components/AttachmentMenu.js +125 -0
  22. package/lib/commonjs/ui/components/AttachmentMenu.js.map +1 -0
  23. package/lib/commonjs/ui/components/ImageViewer.js +181 -0
  24. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -0
  25. package/lib/commonjs/ui/components/InAppNotification.js +210 -0
  26. package/lib/commonjs/ui/components/InAppNotification.js.map +1 -0
  27. package/lib/commonjs/ui/components/TeamAvatars.js +63 -0
  28. package/lib/commonjs/ui/components/TeamAvatars.js.map +1 -0
  29. package/lib/commonjs/ui/components/icons.js +50 -0
  30. package/lib/commonjs/ui/components/icons.js.map +1 -1
  31. package/lib/commonjs/ui/screens/chat/ChatView.js +303 -24
  32. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  33. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +70 -0
  34. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  35. package/lib/module/api/client.js +3 -1
  36. package/lib/module/api/client.js.map +1 -1
  37. package/lib/module/api/realtime.js +2 -0
  38. package/lib/module/api/realtime.js.map +1 -1
  39. package/lib/module/attachments/pickers.js +479 -0
  40. package/lib/module/attachments/pickers.js.map +1 -0
  41. package/lib/module/i18n.js +44 -0
  42. package/lib/module/i18n.js.map +1 -1
  43. package/lib/module/index.js.map +1 -1
  44. package/lib/module/locales.js +21 -0
  45. package/lib/module/locales.js.map +1 -1
  46. package/lib/module/state/widget-store.js +256 -18
  47. package/lib/module/state/widget-store.js.map +1 -1
  48. package/lib/module/types.js +16 -0
  49. package/lib/module/types.js.map +1 -1
  50. package/lib/module/ui/WidgetProvider.js +71 -11
  51. package/lib/module/ui/WidgetProvider.js.map +1 -1
  52. package/lib/module/ui/components/AttachmentChips.js +131 -0
  53. package/lib/module/ui/components/AttachmentChips.js.map +1 -0
  54. package/lib/module/ui/components/AttachmentMenu.js +120 -0
  55. package/lib/module/ui/components/AttachmentMenu.js.map +1 -0
  56. package/lib/module/ui/components/ImageViewer.js +177 -0
  57. package/lib/module/ui/components/ImageViewer.js.map +1 -0
  58. package/lib/module/ui/components/InAppNotification.js +208 -0
  59. package/lib/module/ui/components/InAppNotification.js.map +1 -0
  60. package/lib/module/ui/components/TeamAvatars.js +59 -0
  61. package/lib/module/ui/components/TeamAvatars.js.map +1 -0
  62. package/lib/module/ui/components/icons.js +49 -0
  63. package/lib/module/ui/components/icons.js.map +1 -1
  64. package/lib/module/ui/screens/chat/ChatView.js +306 -27
  65. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  66. package/lib/module/ui/screens/chat/ChatView.styles.js +70 -0
  67. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  68. package/lib/typescript/commonjs/api/client.d.ts +8 -2
  69. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/api/realtime.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/attachments/pickers.d.ts +81 -0
  72. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/i18n.d.ts +23 -0
  74. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/index.d.ts +1 -1
  76. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/state/widget-store.d.ts +40 -2
  79. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/types.d.ts +88 -1
  81. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +16 -0
  84. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts +28 -0
  86. package/lib/typescript/commonjs/ui/components/AttachmentMenu.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +23 -0
  88. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts +28 -0
  90. package/lib/typescript/commonjs/ui/components/InAppNotification.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts +17 -0
  92. package/lib/typescript/commonjs/ui/components/TeamAvatars.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -0
  94. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +3 -2
  96. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +69 -0
  98. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +41 -2
  100. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  101. package/lib/typescript/module/api/client.d.ts +8 -2
  102. package/lib/typescript/module/api/client.d.ts.map +1 -1
  103. package/lib/typescript/module/api/realtime.d.ts.map +1 -1
  104. package/lib/typescript/module/attachments/pickers.d.ts +81 -0
  105. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -0
  106. package/lib/typescript/module/i18n.d.ts +23 -0
  107. package/lib/typescript/module/i18n.d.ts.map +1 -1
  108. package/lib/typescript/module/index.d.ts +1 -1
  109. package/lib/typescript/module/index.d.ts.map +1 -1
  110. package/lib/typescript/module/locales.d.ts.map +1 -1
  111. package/lib/typescript/module/state/widget-store.d.ts +40 -2
  112. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  113. package/lib/typescript/module/types.d.ts +88 -1
  114. package/lib/typescript/module/types.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  116. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +16 -0
  117. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -0
  118. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts +28 -0
  119. package/lib/typescript/module/ui/components/AttachmentMenu.d.ts.map +1 -0
  120. package/lib/typescript/module/ui/components/ImageViewer.d.ts +23 -0
  121. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -0
  122. package/lib/typescript/module/ui/components/InAppNotification.d.ts +28 -0
  123. package/lib/typescript/module/ui/components/InAppNotification.d.ts.map +1 -0
  124. package/lib/typescript/module/ui/components/TeamAvatars.d.ts +17 -0
  125. package/lib/typescript/module/ui/components/TeamAvatars.d.ts.map +1 -0
  126. package/lib/typescript/module/ui/components/icons.d.ts +2 -0
  127. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +3 -2
  129. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +69 -0
  131. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +41 -2
  133. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  134. package/metro.js +17 -32
  135. package/package.json +18 -1
  136. package/src/api/client.ts +5 -3
  137. package/src/api/realtime.ts +2 -0
  138. package/src/attachments/pickers.ts +515 -0
  139. package/src/i18n.ts +48 -0
  140. package/src/index.tsx +4 -0
  141. package/src/locales.ts +21 -0
  142. package/src/state/widget-store.ts +256 -19
  143. package/src/types.ts +95 -0
  144. package/src/ui/WidgetProvider.tsx +90 -9
  145. package/src/ui/components/AttachmentChips.tsx +135 -0
  146. package/src/ui/components/AttachmentMenu.tsx +132 -0
  147. package/src/ui/components/ImageViewer.tsx +178 -0
  148. package/src/ui/components/InAppNotification.tsx +225 -0
  149. package/src/ui/components/TeamAvatars.tsx +58 -0
  150. package/src/ui/components/icons.tsx +37 -0
  151. package/src/ui/screens/chat/ChatView.styles.ts +70 -0
  152. package/src/ui/screens/chat/ChatView.tsx +345 -35
  153. package/src/ui/screens/chat/ChatView.types.ts +51 -2
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Visitor attachments: picking photos/files and turning them into the base64
3
+ * data URLs `POST /api/widget/messages` accepts.
4
+ *
5
+ * The SDK ships no native code, so every picker is borrowed from the host —
6
+ * the same approach as `storage.ts` and `native-gradient.ts`:
7
+ * - `expo-image-picker` → photo library and camera
8
+ * - `expo-document-picker` → any file
9
+ * - `expo-file-system` → reads a picked file as base64 (optional; without it
10
+ * the file is read through `fetch` + `FileReader`)
11
+ * - `expo-image-manipulator` → shrinks photos to fit the 1.5 MB cap (optional;
12
+ * without it a full-resolution camera photo is usually refused as too large)
13
+ *
14
+ * A host without those (or with `react-native-image-picker` etc.) plugs its own
15
+ * picker in through `boot({ attachments: { pick } })`. With neither, the attach
16
+ * button simply isn't shown.
17
+ *
18
+ * No `react-native` import on purpose: the limit and encoding helpers stay
19
+ * testable in plain Node.
20
+ */
21
+
22
+ import type { AttachmentSource, RepzoPickedFile, RepzoWidgetAttachmentOptions } from '../types';
23
+
24
+ /** The server keeps the first five and silently drops the rest. */
25
+ export const MAX_ATTACHMENTS = 5;
26
+ /** Per file, decoded. The server silently drops anything larger. */
27
+ export const MAX_ATTACHMENT_BYTES = 1_500_000;
28
+ /**
29
+ * All files in one message, decoded. Base64 inside JSON inflates bytes by
30
+ * ~1.37x and the server caps a request body at 10 MB, so five files at the
31
+ * per-file maximum would come back as a 413.
32
+ */
33
+ export const MAX_TOTAL_ATTACHMENT_BYTES = 6_000_000;
34
+
35
+ /** Photos are re-encoded at this JPEG quality — small enough to fit the cap more often. */
36
+ const IMAGE_QUALITY = 0.7;
37
+
38
+ /** A picked file, encoded and ready to send; what the composer shows as a chip. */
39
+ export interface DraftAttachment {
40
+ id: string;
41
+ name: string;
42
+ contentType: string;
43
+ /** Decoded bytes — what the server measures against its cap. */
44
+ size: number;
45
+ /** `data:<mime>;base64,<…>` — the wire format. */
46
+ dataUrl: string;
47
+ /** What `<Image>` renders locally: the picked file's URI, else the data URL. */
48
+ previewUri: string;
49
+ }
50
+
51
+ /** Why some (or all) of a pick didn't make it into the composer. */
52
+ export type AttachmentProblem =
53
+ | 'tooMany'
54
+ | 'tooLarge'
55
+ | 'tooLargeTogether'
56
+ | 'cameraDenied'
57
+ | 'failed';
58
+
59
+ export interface PickResult {
60
+ added: DraftAttachment[];
61
+ /** The first thing that went wrong; files that fit are still added. */
62
+ problem: AttachmentProblem | null;
63
+ }
64
+
65
+ // ─── Host integrations ─────────────────────────────────────────────────────
66
+ // Resolved once at module load. Each `require` is static so Metro can see it,
67
+ // and `withRepzoWidget` (metro.js) maps an uninstalled one to an empty module.
68
+
69
+ let imagePicker: any = null;
70
+ try {
71
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
72
+ const mod = require('expo-image-picker');
73
+ if (typeof mod?.launchImageLibraryAsync === 'function') imagePicker = mod;
74
+ } catch {
75
+ imagePicker = null;
76
+ }
77
+
78
+ let documentPicker: any = null;
79
+ try {
80
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
81
+ const mod = require('expo-document-picker');
82
+ if (typeof mod?.getDocumentAsync === 'function') documentPicker = mod;
83
+ } catch {
84
+ documentPicker = null;
85
+ }
86
+
87
+ let fileSystem: any = null;
88
+ try {
89
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
90
+ fileSystem = require('expo-file-system') ?? null;
91
+ } catch {
92
+ fileSystem = null;
93
+ }
94
+
95
+ let imageManipulator: any = null;
96
+ try {
97
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
98
+ const mod = require('expo-image-manipulator');
99
+ if (
100
+ typeof mod?.manipulateAsync === 'function' ||
101
+ typeof mod?.ImageManipulator?.manipulate === 'function'
102
+ ) {
103
+ imageManipulator = mod;
104
+ }
105
+ } catch {
106
+ imageManipulator = null;
107
+ }
108
+
109
+ /** Longest edge of a shrunk photo — plenty for chat, typically 200–500 KB as JPEG. */
110
+ const MAX_IMAGE_EDGE = 1600;
111
+
112
+ let hostOptions: RepzoWidgetAttachmentOptions | null = null;
113
+
114
+ /** Set from `boot({ attachments })`; `null` restores auto-detection. */
115
+ export function setAttachmentOptions(options: RepzoWidgetAttachmentOptions | null): void {
116
+ hostOptions = options;
117
+ }
118
+
119
+ const ALL_SOURCES: AttachmentSource[] = ['photo', 'camera', 'file'];
120
+
121
+ /**
122
+ * The options the attach menu offers, in menu order. Empty means no button:
123
+ * attachments were switched off by the host, or nothing can pick a file.
124
+ */
125
+ export function getAttachmentSources(): AttachmentSource[] {
126
+ if (hostOptions?.enabled === false) return [];
127
+ if (hostOptions?.pick) {
128
+ const wanted = hostOptions.sources ?? ALL_SOURCES;
129
+ return ALL_SOURCES.filter((source) => wanted.includes(source));
130
+ }
131
+ const sources: AttachmentSource[] = [];
132
+ if (imagePicker) sources.push('photo');
133
+ if (typeof imagePicker?.launchCameraAsync === 'function') sources.push('camera');
134
+ if (documentPicker) sources.push('file');
135
+ return sources;
136
+ }
137
+
138
+ // ─── Picking ───────────────────────────────────────────────────────────────
139
+
140
+ /** Returned (not thrown) so no `instanceof` on an Error subclass is needed. */
141
+ const CAMERA_DENIED = 'cameraDenied' as const;
142
+
143
+ /**
144
+ * Opens the picker for `source` and returns what fits alongside `current`.
145
+ * Never throws: a cancelled pick is `{ added: [], problem: null }`.
146
+ */
147
+ export async function pickAttachments(
148
+ source: AttachmentSource,
149
+ current: DraftAttachment[]
150
+ ): Promise<PickResult> {
151
+ const slots = MAX_ATTACHMENTS - current.length;
152
+ if (slots <= 0) return { added: [], problem: 'tooMany' };
153
+
154
+ let picked: RepzoPickedFile[] | null | typeof CAMERA_DENIED;
155
+ try {
156
+ picked = hostOptions?.pick
157
+ ? await hostOptions.pick(source)
158
+ : await launchBuiltInPicker(source, slots);
159
+ } catch (error) {
160
+ console.warn('[repzo-widget] attachment picker failed', error);
161
+ return { added: [], problem: 'failed' };
162
+ }
163
+ if (picked === CAMERA_DENIED) return { added: [], problem: 'cameraDenied' };
164
+ if (!Array.isArray(picked) || picked.length === 0) return { added: [], problem: null };
165
+
166
+ const added: DraftAttachment[] = [];
167
+ let problem: AttachmentProblem | null = null;
168
+ for (const file of picked) {
169
+ const accepted = [...current, ...added];
170
+ if (accepted.length >= MAX_ATTACHMENTS) {
171
+ problem = problem ?? 'tooMany';
172
+ break;
173
+ }
174
+ // A size the picker already knows rules a file out before it's read, so
175
+ // a 40 MB video never lands in memory as a base64 string.
176
+ const declared = file.base64 ? null : validSize(file.size);
177
+ const early = declared === null ? null : checkAttachmentFits(accepted, declared);
178
+ if (early) {
179
+ problem = problem ?? early;
180
+ continue;
181
+ }
182
+
183
+ let draft: DraftAttachment;
184
+ try {
185
+ draft = await toDraft(file);
186
+ } catch (error) {
187
+ if (error === TOO_LARGE) {
188
+ problem = problem ?? 'tooLarge';
189
+ continue;
190
+ }
191
+ console.warn('[repzo-widget] could not read attachment', error);
192
+ problem = problem ?? 'failed';
193
+ continue;
194
+ }
195
+ const fits = draft.size > 0 ? checkAttachmentFits(accepted, draft.size) : 'failed';
196
+ if (fits) {
197
+ problem = problem ?? fits;
198
+ continue;
199
+ }
200
+ added.push(draft);
201
+ }
202
+ return { added, problem };
203
+ }
204
+
205
+ async function launchBuiltInPicker(
206
+ source: AttachmentSource,
207
+ slots: number
208
+ ): Promise<RepzoPickedFile[] | null | typeof CAMERA_DENIED> {
209
+ if (source === 'file') {
210
+ if (!documentPicker) return null;
211
+ const result = await documentPicker.getDocumentAsync({
212
+ type: '*/*',
213
+ multiple: slots > 1,
214
+ copyToCacheDirectory: true,
215
+ });
216
+ // SDK 48+ returns `{ canceled, assets }`; older ones a single `{ type, uri, … }`.
217
+ if (result?.canceled || result?.type === 'cancel') return null;
218
+ const assets: any[] = Array.isArray(result?.assets) ? result.assets : result?.uri ? [result] : [];
219
+ return assets.map((asset) => ({
220
+ name: asset.name || fileNameFromUri(asset.uri) || 'file',
221
+ type: asset.mimeType || '',
222
+ size: asset.size,
223
+ uri: asset.uri,
224
+ }));
225
+ }
226
+
227
+ if (!imagePicker) return null;
228
+ const options = {
229
+ quality: IMAGE_QUALITY,
230
+ // With the manipulator the photo is re-encoded below, so the picker's own
231
+ // (full-resolution) base64 would only waste memory.
232
+ base64: !imageManipulator,
233
+ exif: false,
234
+ };
235
+
236
+ let result: any;
237
+ if (source === 'camera') {
238
+ const permission = await imagePicker.requestCameraPermissionsAsync?.();
239
+ if (permission && permission.granted === false) return CAMERA_DENIED;
240
+ result = await launchImagePicker(imagePicker.launchCameraAsync, options);
241
+ } else {
242
+ result = await launchImagePicker(imagePicker.launchImageLibraryAsync, {
243
+ ...options,
244
+ allowsMultipleSelection: slots > 1,
245
+ selectionLimit: slots,
246
+ });
247
+ }
248
+ if (result?.canceled || result?.cancelled) return null;
249
+ const assets: any[] = Array.isArray(result?.assets) ? result.assets : [];
250
+ const files: RepzoPickedFile[] = [];
251
+ for (const [index, asset] of assets.slice(0, slots).entries()) {
252
+ const shrunk = await shrinkPhoto(asset);
253
+ const type = shrunk
254
+ ? 'image/jpeg'
255
+ : asset.mimeType || guessContentType(asset.fileName || asset.uri) || 'image/jpeg';
256
+ const baseName = (asset.fileName || `photo-${Date.now()}-${index + 1}`).replace(/\.[^.]+$/, '');
257
+ files.push({
258
+ name: `${baseName}.${extensionFor(type)}`,
259
+ type,
260
+ // No `size`: `fileSize` is the original's, not the re-encoded base64's.
261
+ uri: shrunk?.uri ?? asset.uri,
262
+ base64: shrunk?.base64 ?? asset.base64 ?? undefined,
263
+ });
264
+ }
265
+ return files;
266
+ }
267
+
268
+ /**
269
+ * `mediaTypes: ['images']` is the expo-image-picker 16 (SDK 52)+ form; older
270
+ * versions reject the array before any UI shows, so retry with the legacy
271
+ * `MediaTypeOptions.Images` enum they expect.
272
+ */
273
+ async function launchImagePicker(launch: (options: object) => Promise<any>, options: object) {
274
+ try {
275
+ return await launch({ ...options, mediaTypes: ['images'] });
276
+ } catch (error) {
277
+ const legacy = imagePicker?.MediaTypeOptions?.Images;
278
+ if (!legacy) throw error;
279
+ return launch({ ...options, mediaTypes: legacy });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Re-encodes a photo as JPEG, scaled so its longest edge is at most
285
+ * MAX_IMAGE_EDGE. Null when the manipulator isn't installed or fails — the
286
+ * caller then falls back to the picker's own output (and the size check).
287
+ */
288
+ async function shrinkPhoto(asset: any): Promise<{ uri: string; base64: string } | null> {
289
+ if (!imageManipulator || !asset?.uri) return null;
290
+ const width = Number(asset.width) || 0;
291
+ const height = Number(asset.height) || 0;
292
+ const longest = Math.max(width, height);
293
+ const resize =
294
+ longest > MAX_IMAGE_EDGE
295
+ ? width >= height
296
+ ? { width: MAX_IMAGE_EDGE }
297
+ : { height: MAX_IMAGE_EDGE }
298
+ : null;
299
+ const saveOptions = {
300
+ compress: IMAGE_QUALITY,
301
+ format: imageManipulator.SaveFormat?.JPEG ?? 'jpeg',
302
+ base64: true,
303
+ };
304
+ try {
305
+ let output: any;
306
+ if (typeof imageManipulator.manipulateAsync === 'function') {
307
+ output = await imageManipulator.manipulateAsync(asset.uri, resize ? [{ resize }] : [], saveOptions);
308
+ } else {
309
+ const context = imageManipulator.ImageManipulator.manipulate(asset.uri);
310
+ if (resize) context.resize(resize);
311
+ const image = await context.renderAsync();
312
+ output = await image.saveAsync(saveOptions);
313
+ }
314
+ return typeof output?.base64 === 'string' && output.base64
315
+ ? { uri: output.uri ?? asset.uri, base64: output.base64 }
316
+ : null;
317
+ } catch (error) {
318
+ console.warn('[repzo-widget] could not shrink photo — sending it as picked', error);
319
+ return null;
320
+ }
321
+ }
322
+
323
+ async function toDraft(file: RepzoPickedFile): Promise<DraftAttachment> {
324
+ let base64 = file.base64 ? stripDataUrlPrefix(file.base64) : '';
325
+ if (!base64) {
326
+ if (!file.uri) throw new Error('Picked file has neither base64 nor uri');
327
+ base64 = await readBase64(file.uri);
328
+ }
329
+ base64 = base64.replace(/\s/g, '');
330
+
331
+ const name = (file.name || fileNameFromUri(file.uri) || 'file').slice(0, 255);
332
+ const contentType =
333
+ normalizeContentType(file.type) ?? guessContentType(name) ?? 'application/octet-stream';
334
+ const dataUrl = buildDataUrl(contentType, base64);
335
+ return {
336
+ id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
337
+ name,
338
+ contentType,
339
+ size: base64ByteLength(base64),
340
+ dataUrl,
341
+ // Only the chip ever renders a data URL; the store keeps it out of messages.
342
+ previewUri: file.uri || dataUrl,
343
+ };
344
+ }
345
+
346
+ /** Thrown (by identity) when a file is over the per-file cap before it's read. */
347
+ const TOO_LARGE = new Error('File exceeds the attachment cap');
348
+
349
+ /**
350
+ * `expo-file-system`'s `File` class (SDK 54+) first, then its legacy
351
+ * `readAsStringAsync` (SDK 53 and older — a throwing stub on 54), then plain
352
+ * `fetch` + `FileReader`, which RN supports for `file://` URIs.
353
+ *
354
+ * Every path checks the size before encoding: the file-system readers only
355
+ * run once the file is known to fit, so a 300 MB video never becomes a
356
+ * 400 MB base64 string. When the size can't be read, `fetch` measures the blob.
357
+ */
358
+ async function readBase64(uri: string): Promise<string> {
359
+ if (typeof fileSystem?.File === 'function') {
360
+ let file: any = null;
361
+ try {
362
+ file = new fileSystem.File(uri);
363
+ } catch {
364
+ file = null;
365
+ }
366
+ const size = validSize(file?.size);
367
+ if (size !== null && size > MAX_ATTACHMENT_BYTES) throw TOO_LARGE;
368
+ if (size !== null && typeof file.base64 === 'function') {
369
+ try {
370
+ const value = await file.base64();
371
+ if (typeof value === 'string' && value) return value;
372
+ } catch {
373
+ // Fall through to the next reader.
374
+ }
375
+ }
376
+ }
377
+ if (
378
+ typeof fileSystem?.readAsStringAsync === 'function' &&
379
+ typeof fileSystem?.getInfoAsync === 'function'
380
+ ) {
381
+ try {
382
+ const info = await fileSystem.getInfoAsync(uri, { size: true });
383
+ const size = validSize(info?.size);
384
+ if (size !== null && size > MAX_ATTACHMENT_BYTES) throw TOO_LARGE;
385
+ if (size !== null) {
386
+ const value = await fileSystem.readAsStringAsync(uri, { encoding: 'base64' });
387
+ if (typeof value === 'string' && value) return value;
388
+ }
389
+ } catch (error) {
390
+ if (error === TOO_LARGE) throw error;
391
+ // Fall through to fetch.
392
+ }
393
+ }
394
+ return readBase64WithFetch(uri);
395
+ }
396
+
397
+ async function readBase64WithFetch(uri: string): Promise<string> {
398
+ const response = await fetch(uri);
399
+ const blob = await response.blob();
400
+ // Checked before encoding: the per-file cap is the most any file may be.
401
+ if (blob.size > MAX_ATTACHMENT_BYTES) throw TOO_LARGE;
402
+ const dataUrl = await new Promise<string>((resolve, reject) => {
403
+ const reader = new FileReader();
404
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
405
+ reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
406
+ reader.readAsDataURL(blob);
407
+ });
408
+ return stripDataUrlPrefix(dataUrl);
409
+ }
410
+
411
+ // ─── Pure helpers ──────────────────────────────────────────────────────────
412
+
413
+ /** Null when a file of `size` bytes still fits beside `current`; otherwise the limit it breaks. */
414
+ export function checkAttachmentFits(
415
+ current: ReadonlyArray<{ size: number }>,
416
+ size: number
417
+ ): AttachmentProblem | null {
418
+ if (current.length >= MAX_ATTACHMENTS) return 'tooMany';
419
+ if (size > MAX_ATTACHMENT_BYTES) return 'tooLarge';
420
+ const total = current.reduce((sum, item) => sum + item.size, 0);
421
+ if (total + size > MAX_TOTAL_ATTACHMENT_BYTES) return 'tooLargeTogether';
422
+ return null;
423
+ }
424
+
425
+ export function buildDataUrl(contentType: string, base64: string): string {
426
+ return `data:${normalizeContentType(contentType) ?? 'application/octet-stream'};base64,${base64}`;
427
+ }
428
+
429
+ /** RFC 6838 `type/subtype` characters; no parameters, no `,` or `;`. */
430
+ const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/;
431
+
432
+ /**
433
+ * A picker's MIME type as a bare lowercase `type/subtype`, or null when it
434
+ * isn't one. Parameters (`;charset=…`) are dropped: anything else could split
435
+ * the data URL at the wrong `,` and hand the server a forged content type.
436
+ */
437
+ export function normalizeContentType(value: string | null | undefined): string | null {
438
+ if (typeof value !== 'string') return null;
439
+ const bare = value.split(';')[0]!.trim().toLowerCase();
440
+ return MIME_TYPE.test(bare) ? bare : null;
441
+ }
442
+
443
+ /** Accepts raw base64 or a whole data URL; returns the base64 part. */
444
+ export function stripDataUrlPrefix(value: string): string {
445
+ const comma = value.startsWith('data:') ? value.indexOf(',') : -1;
446
+ return comma >= 0 ? value.slice(comma + 1) : value;
447
+ }
448
+
449
+ /** Decoded length of a base64 string (whitespace ignored). */
450
+ export function base64ByteLength(base64: string): number {
451
+ const clean = base64.replace(/\s/g, '');
452
+ if (!clean) return 0;
453
+ const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
454
+ return Math.floor((clean.length * 3) / 4) - padding;
455
+ }
456
+
457
+ /** `1.2 MB`, `340 KB` — for chips; the limits themselves use decimal units too. */
458
+ export function formatBytes(bytes: number): string {
459
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace(/\.0$/, '')} MB`;
460
+ if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`;
461
+ return `${Math.max(0, Math.round(bytes))} B`;
462
+ }
463
+
464
+ export function isImageAttachment(contentType: string | null | undefined): boolean {
465
+ return !!contentType && contentType.toLowerCase().startsWith('image/');
466
+ }
467
+
468
+ function validSize(size: unknown): number | null {
469
+ return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : null;
470
+ }
471
+
472
+ function fileNameFromUri(uri: string | undefined): string | null {
473
+ if (!uri) return null;
474
+ const last = uri.split(/[?#]/)[0]?.split('/').pop();
475
+ if (!last) return null;
476
+ try {
477
+ return decodeURIComponent(last);
478
+ } catch {
479
+ return last;
480
+ }
481
+ }
482
+
483
+ const CONTENT_TYPES: Record<string, string> = {
484
+ jpg: 'image/jpeg',
485
+ jpeg: 'image/jpeg',
486
+ png: 'image/png',
487
+ gif: 'image/gif',
488
+ webp: 'image/webp',
489
+ heic: 'image/heic',
490
+ heif: 'image/heif',
491
+ pdf: 'application/pdf',
492
+ txt: 'text/plain',
493
+ csv: 'text/csv',
494
+ doc: 'application/msword',
495
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
496
+ xls: 'application/vnd.ms-excel',
497
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
498
+ ppt: 'application/vnd.ms-powerpoint',
499
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
500
+ zip: 'application/zip',
501
+ mp3: 'audio/mpeg',
502
+ m4a: 'audio/mp4',
503
+ mp4: 'video/mp4',
504
+ mov: 'video/quicktime',
505
+ };
506
+
507
+ function guessContentType(nameOrUri: string | null | undefined): string | null {
508
+ const extension = /\.([a-z0-9]+)$/i.exec(nameOrUri?.split(/[?#]/)[0] ?? '')?.[1];
509
+ return extension ? (CONTENT_TYPES[extension.toLowerCase()] ?? null) : null;
510
+ }
511
+
512
+ function extensionFor(contentType: string): string {
513
+ const subtype = contentType.split('/')[1]?.toLowerCase();
514
+ return subtype === 'jpeg' || !subtype ? 'jpg' : subtype;
515
+ }
package/src/i18n.ts CHANGED
@@ -29,6 +29,29 @@ export function createTranslator(config: WidgetConfig | null, locale: string): T
29
29
  fallback;
30
30
  }
31
31
 
32
+ /**
33
+ * Dashboard copy that's written per language: the default language's text is
34
+ * the plain config field and every other enabled language's lives under the
35
+ * same key in `translations`.
36
+ */
37
+ const WELCOME_TEXT_KEYS = [
38
+ 'welcomeHeading',
39
+ 'welcomeSubheading',
40
+ 'startConversationLabel',
41
+ 'replyTimeLabel',
42
+ ] as const;
43
+
44
+ /** The config with its welcome text in the active locale. */
45
+ export function localizeWelcomeText(config: WidgetConfig | null, locale: string): WidgetConfig | null {
46
+ if (!config) return config;
47
+ const translate = createTranslator(config, locale);
48
+ const localized = { ...config };
49
+ WELCOME_TEXT_KEYS.forEach((key) => {
50
+ localized[key] = translate(key, config[key]);
51
+ });
52
+ return localized;
53
+ }
54
+
32
55
  export const DEFAULT_LABELS = {
33
56
  back: 'Back',
34
57
  loading: 'Loading...',
@@ -36,6 +59,21 @@ export const DEFAULT_LABELS = {
36
59
  emptyConversation: 'Send a message to start the conversation',
37
60
  messagePlaceholder: 'Type a message...',
38
61
  attachFile: 'Attach file',
62
+ // Attach menu, composer chips and the notices under them.
63
+ attachPhotoLibrary: 'Photo library',
64
+ attachTakePhoto: 'Take photo',
65
+ attachChooseFile: 'File',
66
+ removeAttachment: 'Remove {name}',
67
+ attachmentTooLarge: 'File is too large (max {max})',
68
+ attachmentTooMany: 'You can attach up to {count} files',
69
+ attachmentsTooLargeTogether:
70
+ 'Attachments are too large to send together — send some in another message',
71
+ attachmentFailed: "Couldn't attach that file",
72
+ // Full-screen image viewer.
73
+ imageLoadFailed: "Couldn't load this image",
74
+ openImageExternally: 'Open image',
75
+ cameraPermissionDenied: 'Allow camera access in Settings to take a photo',
76
+ cancel: 'Cancel',
39
77
  failedToSend: 'Failed to send',
40
78
  thinking: 'Typing',
41
79
  messages: 'Messages',
@@ -75,6 +113,11 @@ export const DEFAULT_LABELS = {
75
113
  today: 'Today',
76
114
  yesterday: 'Yesterday',
77
115
  conversationUpdated: 'Conversation updated',
116
+ messageDeleted: 'This message was deleted',
117
+ messageSent: 'Sent',
118
+ messageSeen: 'Seen',
119
+ waitingForTeammate: 'Waiting for a teammate',
120
+ joinedConversation: 'joined the conversation',
78
121
  // Forms.
79
122
  procedureSend: 'Send details',
80
123
  procedureSent: 'Sent',
@@ -85,6 +128,11 @@ export const DEFAULT_LABELS = {
85
128
  outboundTitle: 'New update',
86
129
  outboundCta: 'Learn more',
87
130
  dismiss: 'Dismiss',
131
+ // In-app card for a new reply while the messenger is closed.
132
+ notificationReplyFrom: 'Reply from {name}',
133
+ notificationNewReply: 'New reply',
134
+ notificationAttachment: 'Sent an attachment',
135
+ notificationOpenHint: 'Opens the conversation',
88
136
  // Rich blocks an agent can send.
89
137
  blockService: 'Service',
90
138
  blockProduct: 'Product',
package/src/index.tsx CHANGED
@@ -27,11 +27,15 @@ export { useWidgetState } from './state/use-widget-state';
27
27
  export type {
28
28
  AgentUiAction,
29
29
  AgentUiBlock,
30
+ AttachmentSource,
30
31
  BootOptions,
31
32
  Conversation,
33
+ InAppNotification,
32
34
  Message,
33
35
  OutboundMessage,
34
36
  PreChatField,
37
+ RepzoPickedFile,
38
+ RepzoWidgetAttachmentOptions,
35
39
  RepzoWidgetErrorCode,
36
40
  RepzoWidgetStorageAdapter,
37
41
  WidgetAttachment,
package/src/locales.ts CHANGED
@@ -21,6 +21,18 @@ const ar: LabelMap = {
21
21
  emptyConversation: 'أرسل رسالة لبدء المحادثة',
22
22
  messagePlaceholder: 'اكتب رسالة...',
23
23
  attachFile: 'إرفاق ملف',
24
+ attachPhotoLibrary: 'مكتبة الصور',
25
+ attachTakePhoto: 'التقاط صورة',
26
+ attachChooseFile: 'ملف',
27
+ removeAttachment: 'إزالة {name}',
28
+ attachmentTooLarge: 'الملف كبير جدًا (الحد الأقصى {max})',
29
+ attachmentTooMany: 'يمكنك إرفاق {count} ملفات كحد أقصى',
30
+ attachmentsTooLargeTogether: 'المرفقات كبيرة جدًا لإرسالها معًا — أرسل بعضها في رسالة أخرى',
31
+ attachmentFailed: 'تعذر إرفاق هذا الملف',
32
+ imageLoadFailed: 'تعذر تحميل هذه الصورة',
33
+ openImageExternally: 'فتح الصورة',
34
+ cameraPermissionDenied: 'اسمح بالوصول إلى الكاميرا من الإعدادات لالتقاط صورة',
35
+ cancel: 'إلغاء',
24
36
  failedToSend: 'تعذر الإرسال',
25
37
  thinking: 'يكتب',
26
38
  messages: 'الرسائل',
@@ -57,6 +69,11 @@ const ar: LabelMap = {
57
69
  today: 'اليوم',
58
70
  yesterday: 'أمس',
59
71
  conversationUpdated: 'تم تحديث المحادثة',
72
+ messageDeleted: 'تم حذف هذه الرسالة',
73
+ messageSent: 'تم الإرسال',
74
+ messageSeen: 'تمت المشاهدة',
75
+ waitingForTeammate: 'بانتظار أحد أعضاء الفريق',
76
+ joinedConversation: 'انضم إلى المحادثة',
60
77
  procedureSend: 'إرسال التفاصيل',
61
78
  procedureSent: 'تم الإرسال',
62
79
  fieldRequired: '{field} مطلوب',
@@ -65,6 +82,10 @@ const ar: LabelMap = {
65
82
  outboundTitle: 'تحديث جديد',
66
83
  outboundCta: 'اعرف المزيد',
67
84
  dismiss: 'تجاهل',
85
+ notificationReplyFrom: 'رد من {name}',
86
+ notificationNewReply: 'رد جديد',
87
+ notificationAttachment: 'أرسل مرفقًا',
88
+ notificationOpenHint: 'يفتح المحادثة',
68
89
  blockService: 'خدمة',
69
90
  blockProduct: 'منتج',
70
91
  blockChooseTime: 'اختر وقتًا',