turbodesk-livechat-react-native 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (55) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/api/conversation-api.d.ts +9 -0
  3. package/dist/api/conversation-api.d.ts.map +1 -1
  4. package/dist/api/conversation-api.js +11 -1
  5. package/dist/api/conversation-api.js.map +1 -1
  6. package/dist/hooks/use-send-message.d.ts +1 -0
  7. package/dist/hooks/use-send-message.d.ts.map +1 -1
  8. package/dist/hooks/use-send-message.js +20 -14
  9. package/dist/hooks/use-send-message.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/navigation/LiveChatPanel.d.ts.map +1 -1
  15. package/dist/navigation/LiveChatPanel.js +12 -1
  16. package/dist/navigation/LiveChatPanel.js.map +1 -1
  17. package/dist/provider/LiveChatProvider.d.ts +1 -1
  18. package/dist/provider/LiveChatProvider.d.ts.map +1 -1
  19. package/dist/provider/LiveChatProvider.js +13 -2
  20. package/dist/provider/LiveChatProvider.js.map +1 -1
  21. package/dist/provider/types.d.ts +2 -0
  22. package/dist/provider/types.d.ts.map +1 -1
  23. package/dist/ui/components/ConversationHeader.d.ts.map +1 -1
  24. package/dist/ui/components/ConversationHeader.js +7 -4
  25. package/dist/ui/components/ConversationHeader.js.map +1 -1
  26. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -1
  27. package/dist/ui/components/ConversationListScreen.js +11 -2
  28. package/dist/ui/components/ConversationListScreen.js.map +1 -1
  29. package/dist/ui/components/ConversationScreen.d.ts.map +1 -1
  30. package/dist/ui/components/ConversationScreen.js +11 -2
  31. package/dist/ui/components/ConversationScreen.js.map +1 -1
  32. package/dist/ui/components/HomeScreen.d.ts.map +1 -1
  33. package/dist/ui/components/HomeScreen.js +14 -4
  34. package/dist/ui/components/HomeScreen.js.map +1 -1
  35. package/dist/ui/components/MessageComposer.d.ts +1 -0
  36. package/dist/ui/components/MessageComposer.d.ts.map +1 -1
  37. package/dist/ui/components/MessageComposer.js +129 -51
  38. package/dist/ui/components/MessageComposer.js.map +1 -1
  39. package/dist/ui/safe-area.d.ts +9 -0
  40. package/dist/ui/safe-area.d.ts.map +1 -0
  41. package/dist/ui/safe-area.js +28 -0
  42. package/dist/ui/safe-area.js.map +1 -0
  43. package/package.json +9 -5
  44. package/src/api/conversation-api.ts +11 -0
  45. package/src/hooks/use-send-message.ts +169 -159
  46. package/src/index.ts +3 -0
  47. package/src/navigation/LiveChatPanel.tsx +15 -3
  48. package/src/provider/LiveChatProvider.tsx +16 -4
  49. package/src/provider/types.ts +2 -0
  50. package/src/ui/components/ConversationHeader.tsx +7 -6
  51. package/src/ui/components/ConversationListScreen.tsx +18 -5
  52. package/src/ui/components/ConversationScreen.tsx +23 -7
  53. package/src/ui/components/HomeScreen.tsx +21 -6
  54. package/src/ui/components/MessageComposer.tsx +166 -57
  55. package/src/ui/safe-area.ts +34 -0
@@ -22,6 +22,7 @@ import {
22
22
  getWidgetName,
23
23
  } from "../theme";
24
24
  import { HistoryChatsIcon, SendPlaneIcon } from "../icons";
25
+ import { usePanelSafeAreaInsets } from "../safe-area";
25
26
 
26
27
  let LinearGradient: any = null;
27
28
  try {
@@ -95,6 +96,7 @@ export function HomeScreen({
95
96
  visitorQueryParams,
96
97
  embedLoadState,
97
98
  } = useLiveChatContext();
99
+ const { top: safeTop, bottom: safeBottom } = usePanelSafeAreaInsets();
98
100
 
99
101
  const [conversationCount, setConversationCount] = useState(0);
100
102
 
@@ -133,7 +135,7 @@ export function HomeScreen({
133
135
  style={styles.flex}
134
136
  >
135
137
  {/* top — branding */}
136
- <View style={styles.header}>
138
+ <View style={[styles.header, { paddingTop: safeTop + 20 }]}>
137
139
  {logoUrl ? (
138
140
  <Image
139
141
  source={{ uri: logoUrl }}
@@ -143,11 +145,17 @@ export function HomeScreen({
143
145
  ) : (
144
146
  <Avatar name={widgetName} size={44} />
145
147
  )}
146
- <Text style={[styles.headerTitle, { color: contrastText }]} numberOfLines={2}>
148
+ <Text
149
+ style={[styles.headerTitle, { color: contrastText }]}
150
+ numberOfLines={2}
151
+ >
147
152
  {title}
148
153
  </Text>
149
154
  <Text
150
- style={[styles.headerSubtitle, { color: contrastText, opacity: 0.82 }]}
155
+ style={[
156
+ styles.headerSubtitle,
157
+ { color: contrastText, opacity: 0.82 },
158
+ ]}
151
159
  numberOfLines={3}
152
160
  >
153
161
  {description}
@@ -182,7 +190,10 @@ export function HomeScreen({
182
190
  Previous chats
183
191
  {conversationCount > 1 ? (
184
192
  <Text
185
- style={{ fontWeight: "400", color: "rgba(255,255,255,0.7)" }}
193
+ style={{
194
+ fontWeight: "400",
195
+ color: "rgba(255,255,255,0.7)",
196
+ }}
186
197
  >
187
198
  {" "}
188
199
  ({conversationCount})
@@ -219,7 +230,12 @@ export function HomeScreen({
219
230
  </View>
220
231
  </View>
221
232
 
222
- <Text style={[styles.poweredBy, { color: "rgba(255,255,255,0.7)" }]}>
233
+ <Text
234
+ style={[
235
+ styles.poweredBy,
236
+ { color: "rgba(255,255,255,0.7)", paddingBottom: 8 + safeBottom },
237
+ ]}
238
+ >
223
239
  {POWERED_BY_TEXT}
224
240
  </Text>
225
241
  </GradientBackground>
@@ -231,7 +247,6 @@ const styles = StyleSheet.create({
231
247
  header: {
232
248
  flex: 0.3,
233
249
  paddingHorizontal: 20,
234
- paddingTop: 52,
235
250
  paddingBottom: 32,
236
251
  gap: 12,
237
252
  justifyContent: "flex-start",
@@ -3,6 +3,8 @@ import {
3
3
  ActivityIndicator,
4
4
  Alert,
5
5
  Image,
6
+ Keyboard,
7
+ Platform,
6
8
  ScrollView,
7
9
  StyleSheet,
8
10
  Text,
@@ -13,6 +15,7 @@ import {
13
15
  import { useLiveChatContext } from "../../provider/LiveChatContext";
14
16
  import { fileApi } from "../../api/file-api";
15
17
  import { SendPlaneIcon, PaperclipIcon, CloseIcon } from "../icons";
18
+ import { usePanelSafeAreaInsets } from "../safe-area";
16
19
 
17
20
  // ── types ─────────────────────────────────────────────────────────────────────
18
21
 
@@ -41,6 +44,7 @@ export type PendingAttachment = {
41
44
  uri: string;
42
45
  name: string;
43
46
  mimeType: string;
47
+ size?: number;
44
48
  kind: PickedAttachmentKind;
45
49
  };
46
50
 
@@ -80,32 +84,43 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
80
84
  try {
81
85
  let picked: PickedAttachment | null = null;
82
86
 
83
- if (kind === "image" || kind === "video") {
84
- let ImagePicker: any = null;
85
- try { ImagePicker = require("react-native-image-picker"); } catch { /* optional */ }
86
- if (!ImagePicker) { Alert.alert("Missing dependency", "Install react-native-image-picker to pick images."); return; }
87
+ if (kind === "image") {
88
+ const ImagePicker = require("react-native-image-picker");
89
+ await new Promise<void>((resolve) => {
90
+ ImagePicker.launchImageLibrary({ mediaType: "photo", quality: 0.8 }, (res: any) => {
91
+ if (res.didCancel || res.errorCode) { resolve(); return; }
92
+ const asset = res.assets?.[0];
93
+ if (!asset) { resolve(); return; }
94
+ picked = {
95
+ uri: asset.uri,
96
+ name: asset.fileName ?? "image.jpg",
97
+ type: asset.type ?? "image/jpeg",
98
+ size: asset.fileSize,
99
+ kind: "image",
100
+ };
101
+ resolve();
102
+ });
103
+ });
104
+ } else if (kind === "video") {
105
+ const ImagePicker = require("react-native-image-picker");
87
106
  await new Promise<void>((resolve) => {
88
- ImagePicker.launchImageLibrary(
89
- kind === "image" ? { mediaType: "photo", quality: 0.8 } : { mediaType: "video" },
90
- (res: any) => {
91
- if (res.didCancel || res.errorCode) { resolve(); return; }
92
- const asset = res.assets?.[0];
93
- if (!asset) { resolve(); return; }
94
- picked = {
95
- uri: asset.uri,
96
- name: asset.fileName ?? (kind === "image" ? "image.jpg" : "video.mp4"),
97
- type: asset.type ?? (kind === "image" ? "image/jpeg" : "video/mp4"),
98
- size: asset.fileSize,
99
- kind,
100
- };
101
- resolve();
102
- }
103
- );
107
+ ImagePicker.launchImageLibrary({ mediaType: "video" }, (res: any) => {
108
+ if (res.didCancel || res.errorCode) { resolve(); return; }
109
+ const asset = res.assets?.[0];
110
+ if (!asset) { resolve(); return; }
111
+ picked = {
112
+ uri: asset.uri,
113
+ name: asset.fileName ?? "video.mp4",
114
+ type: asset.type ?? "video/mp4",
115
+ size: asset.fileSize,
116
+ kind: "video",
117
+ };
118
+ resolve();
119
+ });
104
120
  });
105
121
  } else {
106
- let DocumentPicker: any = null;
107
- try { DocumentPicker = require("react-native-document-picker"); } catch { /* optional */ }
108
- if (!DocumentPicker) { Alert.alert("Missing dependency", "Install react-native-document-picker to pick files."); return; }
122
+ // audio / document — use @react-native-documents/picker
123
+ const DocumentPicker = require("@react-native-documents/picker");
109
124
  const types = kind === "audio"
110
125
  ? [DocumentPicker.types.audio]
111
126
  : [DocumentPicker.types.pdf, DocumentPicker.types.plainText, DocumentPicker.types.doc, DocumentPicker.types.docx];
@@ -124,7 +139,7 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
124
139
 
125
140
  if (picked) onPick(picked);
126
141
  } catch (e: any) {
127
- if (e?.code !== "DOCUMENT_PICKER_CANCELED") {
142
+ if (e?.code !== "DOCUMENT_PICKER_CANCELED" && e?.code !== "OPERATION_CANCELED") {
128
143
  Alert.alert("Error", "Could not pick file.");
129
144
  }
130
145
  }
@@ -212,42 +227,61 @@ function FilePreviews({
212
227
 
213
228
  // ── upload helper ─────────────────────────────────────────────────────────────
214
229
 
230
+ // Throws on any failure so the caller can surface the error and preserve the draft.
215
231
  async function uploadAttachment(
216
232
  pending: PendingAttachment,
217
233
  visitorQueryParams: Record<string, string> | null
218
- ): Promise<UploadedAttachment | null> {
234
+ ): Promise<UploadedAttachment> {
235
+ const ext = pending.name.split(".").pop() ?? "";
236
+ let presigned: any;
219
237
  try {
220
- const ext = pending.name.split(".").pop() ?? "";
221
- const presigned = await fileApi.getFileUrl(
238
+ presigned = await fileApi.getFileUrl(
222
239
  {
223
240
  operation: "putObject",
224
241
  name: pending.name,
225
242
  type: pending.mimeType,
226
243
  extension: ext,
227
- fileMode: "app",
244
+ bytes: pending.size,
245
+ folder: "livechat",
228
246
  },
229
247
  visitorQueryParams ? { params: visitorQueryParams } : undefined
230
- ) as any;
248
+ );
249
+ } catch {
250
+ throw new Error(`Failed to prepare upload for "${pending.name}"`);
251
+ }
252
+
253
+ const signedUrl = presigned?.data?.signedUrl;
254
+ if (!signedUrl) throw new Error(`No upload URL returned for "${pending.name}"`);
231
255
 
232
- await fetch(presigned?.data?.signedUrl, {
256
+ let putRes: Response;
257
+ try {
258
+ putRes = await fetch(signedUrl, {
233
259
  method: "PUT",
234
260
  headers: { "Content-Type": pending.mimeType },
235
261
  body: { uri: pending.uri, type: pending.mimeType, name: pending.name } as any,
236
262
  });
237
-
238
- const uploaded = presigned?.data?.file;
239
- return {
240
- id: pending.id,
241
- fileId: uploaded._id,
242
- fileUrl: uploaded.url,
243
- fileName: uploaded.name,
244
- fileExtension: uploaded.extension,
245
- fileType: pending.mimeType,
246
- type: pending.kind,
247
- };
248
263
  } catch {
249
- return null;
264
+ throw new Error(`Network error while uploading "${pending.name}"`);
265
+ }
266
+
267
+ if (!putRes.ok) {
268
+ throw new Error(`Upload failed for "${pending.name}" (HTTP ${putRes.status})`);
250
269
  }
270
+
271
+ const uploaded = presigned?.data?.file;
272
+ if (!uploaded?._id) {
273
+ throw new Error(`Upload succeeded but server returned no file metadata for "${pending.name}"`);
274
+ }
275
+
276
+ return {
277
+ id: pending.id,
278
+ fileId: uploaded._id,
279
+ fileUrl: uploaded.url,
280
+ fileName: uploaded.name,
281
+ fileExtension: uploaded.extension,
282
+ fileType: pending.mimeType,
283
+ type: pending.kind,
284
+ };
251
285
  }
252
286
 
253
287
  // ── main composer ─────────────────────────────────────────────────────────────
@@ -258,18 +292,42 @@ export function MessageComposer({
258
292
  placeholder = "Message…",
259
293
  }: MessageComposerProps) {
260
294
  const { theme: t, visitorQueryParams } = useLiveChatContext();
295
+ const { bottom: safeBottom } = usePanelSafeAreaInsets();
296
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
261
297
  const [text, setText] = useState("");
262
298
  const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
263
299
  const [isSending, setIsSending] = useState(false);
300
+ const [sendError, setSendError] = useState<string | null>(null);
264
301
  const inputRef = useRef<TextInput>(null);
265
302
 
303
+ const isConnecting = !visitorQueryParams;
266
304
  const trimmed = text.trim();
267
- const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending;
305
+ const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending && !isConnecting;
306
+
307
+ useEffect(() => {
308
+ const show = Keyboard.addListener(
309
+ Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",
310
+ () => setKeyboardVisible(true)
311
+ );
312
+ const hide = Keyboard.addListener(
313
+ Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",
314
+ () => setKeyboardVisible(false)
315
+ );
316
+ return () => {
317
+ show.remove();
318
+ hide.remove();
319
+ };
320
+ }, []);
321
+
322
+ // Clear stale error when the user edits the message
323
+ useEffect(() => {
324
+ if (sendError && (text || attachments.length)) setSendError(null);
325
+ }, [text, attachments.length]);
268
326
 
269
327
  const handlePick = useCallback((item: PickedAttachment) => {
270
328
  setAttachments((prev) => {
271
329
  if (prev.length >= MAX_ATTACHMENTS) return prev;
272
- return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, kind: item.kind }];
330
+ return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, size: item.size, kind: item.kind }];
273
331
  });
274
332
  }, []);
275
333
 
@@ -279,26 +337,61 @@ export function MessageComposer({
279
337
 
280
338
  const handleSend = useCallback(async () => {
281
339
  if (!canSend) return;
340
+
282
341
  setIsSending(true);
283
- const snapshot = [...attachments];
342
+ setSendError(null);
343
+
344
+ // Capture current values — text/attachments must NOT be cleared until send succeeds
284
345
  const sendText = text.trim();
346
+ const snapshot = [...attachments];
347
+
285
348
  try {
286
- const uploadResults = await Promise.all(
287
- snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
288
- );
289
- const uploaded = uploadResults.filter((r): r is UploadedAttachment => r !== null);
349
+ let uploaded: UploadedAttachment[] = [];
350
+
351
+ if (snapshot.length > 0) {
352
+ // Upload all attachments throws on the first failure, preserving draft
353
+ uploaded = await Promise.all(
354
+ snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
355
+ );
356
+ }
357
+
358
+ // onSend must throw on failure (ConversationScreen propagates throws from useSendMessage)
290
359
  await onSend(sendText, uploaded);
360
+
361
+ // Only reached on confirmed success — safe to clear
291
362
  setText("");
292
363
  setAttachments([]);
293
- } catch {
294
- /* ignore */
364
+ setSendError(null);
365
+ } catch (e: any) {
366
+ // Draft preserved — text and attachments state untouched
367
+ setSendError(e?.message ?? "Failed to send. Please try again.");
295
368
  } finally {
296
369
  setIsSending(false);
297
370
  }
298
371
  }, [canSend, text, attachments, onSend, visitorQueryParams]);
299
372
 
300
373
  return (
301
- <View style={[styles.container, { backgroundColor: t.colors.composerBackground, borderTopColor: t.colors.composerBorder }]}>
374
+ <View
375
+ style={[
376
+ styles.container,
377
+ {
378
+ backgroundColor: t.colors.composerBackground,
379
+ borderTopColor: t.colors.composerBorder,
380
+ paddingBottom: 8 + (keyboardVisible ? 0 : safeBottom),
381
+ },
382
+ ]}
383
+ >
384
+ {sendError ? (
385
+ <View style={[styles.errorBanner, { backgroundColor: t.colors.surface, borderColor: t.colors.border }]}>
386
+ <Text style={[styles.errorText, { color: t.colors.error ?? "#c0392b" }]} numberOfLines={2}>
387
+ {sendError}
388
+ </Text>
389
+ <TouchableOpacity onPress={() => setSendError(null)} accessibilityLabel="Dismiss error">
390
+ <CloseIcon size={14} color={t.colors.textMuted} />
391
+ </TouchableOpacity>
392
+ </View>
393
+ ) : null}
394
+
302
395
  <View style={[styles.inner, { borderColor: t.colors.composerBorder }]}>
303
396
  <FilePreviews items={attachments} onRemove={handleRemove} borderColor={t.colors.border} />
304
397
 
@@ -307,11 +400,11 @@ export function MessageComposer({
307
400
  style={[styles.input, { color: t.colors.inputText, fontSize: t.fontSizes.md }]}
308
401
  value={text}
309
402
  onChangeText={setText}
310
- placeholder={placeholder}
403
+ placeholder={isConnecting ? "Connecting…" : placeholder}
311
404
  placeholderTextColor={t.colors.textMuted}
312
405
  multiline
313
406
  maxLength={4000}
314
- editable={!disabled}
407
+ editable={!disabled && !isConnecting}
315
408
  returnKeyType="default"
316
409
  blurOnSubmit={false}
317
410
  />
@@ -319,7 +412,7 @@ export function MessageComposer({
319
412
  <View style={styles.toolbar}>
320
413
  <AttachmentMenu
321
414
  onPick={handlePick}
322
- disabled={disabled || isSending}
415
+ disabled={disabled || isSending || isConnecting}
323
416
  attachmentCount={attachments.length}
324
417
  color={t.colors.textMuted}
325
418
  />
@@ -332,10 +425,12 @@ export function MessageComposer({
332
425
  styles.sendBtn,
333
426
  { backgroundColor: canSend ? t.colors.sendButtonBackground : t.colors.border },
334
427
  ]}
335
- accessibilityLabel="Send message"
428
+ accessibilityLabel={isConnecting ? "Connecting, please wait" : "Send message"}
336
429
  >
337
430
  {isSending ? (
338
431
  <ActivityIndicator size="small" color={t.colors.sendButtonIcon} />
432
+ ) : isConnecting ? (
433
+ <ActivityIndicator size="small" color={t.colors.textMuted} />
339
434
  ) : (
340
435
  <SendPlaneIcon size={18} color={canSend ? t.colors.sendButtonIcon : t.colors.textMuted} />
341
436
  )}
@@ -351,7 +446,6 @@ const styles = StyleSheet.create({
351
446
  borderTopWidth: 1,
352
447
  paddingHorizontal: 8,
353
448
  paddingTop: 8,
354
- paddingBottom: 8,
355
449
  },
356
450
  inner: {
357
451
  borderWidth: 1,
@@ -360,6 +454,21 @@ const styles = StyleSheet.create({
360
454
  paddingTop: 10,
361
455
  paddingBottom: 6,
362
456
  },
457
+ errorBanner: {
458
+ flexDirection: "row",
459
+ alignItems: "center",
460
+ justifyContent: "space-between",
461
+ borderWidth: 1,
462
+ borderRadius: 8,
463
+ paddingHorizontal: 12,
464
+ paddingVertical: 8,
465
+ marginBottom: 6,
466
+ gap: 8,
467
+ },
468
+ errorText: {
469
+ flex: 1,
470
+ fontSize: 13,
471
+ },
363
472
  input: {
364
473
  minHeight: 30,
365
474
  maxHeight: 200,
@@ -0,0 +1,34 @@
1
+ import { Platform, StatusBar } from "react-native";
2
+
3
+ export type PanelSafeAreaInsets = {
4
+ top: number;
5
+ bottom: number;
6
+ left: number;
7
+ right: number;
8
+ };
9
+
10
+ function getFallbackInsets(): PanelSafeAreaInsets {
11
+ if (Platform.OS === "ios") {
12
+ // Typical notch / home-indicator insets when no SafeAreaProvider is mounted.
13
+ return { top: 47, bottom: 34, left: 0, right: 0 };
14
+ }
15
+ return { top: StatusBar.currentHeight ?? 0, bottom: 0, left: 0, right: 0 };
16
+ }
17
+
18
+ type UseSafeAreaInsets = () => PanelSafeAreaInsets;
19
+
20
+ let useSafeAreaInsetsFromContext: UseSafeAreaInsets | null = null;
21
+ try {
22
+ useSafeAreaInsetsFromContext =
23
+ require("react-native-safe-area-context").useSafeAreaInsets;
24
+ } catch {
25
+ useSafeAreaInsetsFromContext = null;
26
+ }
27
+
28
+ /** Safe-area insets for panel UI. Uses react-native-safe-area-context when available. */
29
+ export function usePanelSafeAreaInsets(): PanelSafeAreaInsets {
30
+ if (useSafeAreaInsetsFromContext) {
31
+ return useSafeAreaInsetsFromContext();
32
+ }
33
+ return getFallbackInsets();
34
+ }