turbodesk-livechat-react-native 0.1.0-alpha.3 → 0.1.0-alpha.30

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 (64) hide show
  1. package/CHANGELOG.md +50 -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 +15 -1
  5. package/dist/api/conversation-api.js.map +1 -1
  6. package/dist/hooks/use-live-chat.d.ts +1 -0
  7. package/dist/hooks/use-live-chat.d.ts.map +1 -1
  8. package/dist/hooks/use-live-chat.js +2 -0
  9. package/dist/hooks/use-live-chat.js.map +1 -1
  10. package/dist/hooks/use-send-message.d.ts +1 -0
  11. package/dist/hooks/use-send-message.d.ts.map +1 -1
  12. package/dist/hooks/use-send-message.js +20 -14
  13. package/dist/hooks/use-send-message.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/navigation/LiveChatPanel.d.ts.map +1 -1
  19. package/dist/navigation/LiveChatPanel.js +20 -1
  20. package/dist/navigation/LiveChatPanel.js.map +1 -1
  21. package/dist/provider/LiveChatContext.d.ts.map +1 -1
  22. package/dist/provider/LiveChatContext.js +2 -0
  23. package/dist/provider/LiveChatContext.js.map +1 -1
  24. package/dist/provider/LiveChatProvider.d.ts +1 -1
  25. package/dist/provider/LiveChatProvider.d.ts.map +1 -1
  26. package/dist/provider/LiveChatProvider.js +17 -2
  27. package/dist/provider/LiveChatProvider.js.map +1 -1
  28. package/dist/provider/types.d.ts +4 -0
  29. package/dist/provider/types.d.ts.map +1 -1
  30. package/dist/ui/components/ConversationHeader.d.ts.map +1 -1
  31. package/dist/ui/components/ConversationHeader.js +7 -4
  32. package/dist/ui/components/ConversationHeader.js.map +1 -1
  33. package/dist/ui/components/ConversationListScreen.d.ts.map +1 -1
  34. package/dist/ui/components/ConversationListScreen.js +11 -2
  35. package/dist/ui/components/ConversationListScreen.js.map +1 -1
  36. package/dist/ui/components/ConversationScreen.d.ts.map +1 -1
  37. package/dist/ui/components/ConversationScreen.js +13 -3
  38. package/dist/ui/components/ConversationScreen.js.map +1 -1
  39. package/dist/ui/components/HomeScreen.d.ts.map +1 -1
  40. package/dist/ui/components/HomeScreen.js +14 -4
  41. package/dist/ui/components/HomeScreen.js.map +1 -1
  42. package/dist/ui/components/MessageComposer.d.ts +1 -0
  43. package/dist/ui/components/MessageComposer.d.ts.map +1 -1
  44. package/dist/ui/components/MessageComposer.js +89 -30
  45. package/dist/ui/components/MessageComposer.js.map +1 -1
  46. package/dist/ui/safe-area.d.ts +9 -0
  47. package/dist/ui/safe-area.d.ts.map +1 -0
  48. package/dist/ui/safe-area.js +28 -0
  49. package/dist/ui/safe-area.js.map +1 -0
  50. package/package.json +11 -3
  51. package/src/api/conversation-api.ts +33 -8
  52. package/src/hooks/use-live-chat.ts +3 -0
  53. package/src/hooks/use-send-message.ts +169 -159
  54. package/src/index.ts +3 -0
  55. package/src/navigation/LiveChatPanel.tsx +26 -3
  56. package/src/provider/LiveChatContext.ts +2 -0
  57. package/src/provider/LiveChatProvider.tsx +396 -380
  58. package/src/provider/types.ts +63 -57
  59. package/src/ui/components/ConversationHeader.tsx +7 -6
  60. package/src/ui/components/ConversationListScreen.tsx +18 -5
  61. package/src/ui/components/ConversationScreen.tsx +369 -362
  62. package/src/ui/components/HomeScreen.tsx +21 -6
  63. package/src/ui/components/MessageComposer.tsx +116 -36
  64. 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",
@@ -13,6 +13,7 @@ import {
13
13
  import { useLiveChatContext } from "../../provider/LiveChatContext";
14
14
  import { fileApi } from "../../api/file-api";
15
15
  import { SendPlaneIcon, PaperclipIcon, CloseIcon } from "../icons";
16
+ import { usePanelSafeAreaInsets } from "../safe-area";
16
17
 
17
18
  // ── types ─────────────────────────────────────────────────────────────────────
18
19
 
@@ -41,6 +42,7 @@ export type PendingAttachment = {
41
42
  uri: string;
42
43
  name: string;
43
44
  mimeType: string;
45
+ size?: number;
44
46
  kind: PickedAttachmentKind;
45
47
  };
46
48
 
@@ -115,8 +117,8 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
115
117
  });
116
118
  });
117
119
  } else {
118
- // audio / document — use react-native-document-picker
119
- const DocumentPicker = require("react-native-document-picker");
120
+ // audio / document — use @react-native-documents/picker
121
+ const DocumentPicker = require("@react-native-documents/picker");
120
122
  const types = kind === "audio"
121
123
  ? [DocumentPicker.types.audio]
122
124
  : [DocumentPicker.types.pdf, DocumentPicker.types.plainText, DocumentPicker.types.doc, DocumentPicker.types.docx];
@@ -135,7 +137,7 @@ function AttachmentMenu({ onPick, disabled, attachmentCount, color }: Attachment
135
137
 
136
138
  if (picked) onPick(picked);
137
139
  } catch (e: any) {
138
- if (e?.code !== "DOCUMENT_PICKER_CANCELED") {
140
+ if (e?.code !== "DOCUMENT_PICKER_CANCELED" && e?.code !== "OPERATION_CANCELED") {
139
141
  Alert.alert("Error", "Could not pick file.");
140
142
  }
141
143
  }
@@ -223,42 +225,61 @@ function FilePreviews({
223
225
 
224
226
  // ── upload helper ─────────────────────────────────────────────────────────────
225
227
 
228
+ // Throws on any failure so the caller can surface the error and preserve the draft.
226
229
  async function uploadAttachment(
227
230
  pending: PendingAttachment,
228
231
  visitorQueryParams: Record<string, string> | null
229
- ): Promise<UploadedAttachment | null> {
232
+ ): Promise<UploadedAttachment> {
233
+ const ext = pending.name.split(".").pop() ?? "";
234
+ let presigned: any;
230
235
  try {
231
- const ext = pending.name.split(".").pop() ?? "";
232
- const presigned = await fileApi.getFileUrl(
236
+ presigned = await fileApi.getFileUrl(
233
237
  {
234
238
  operation: "putObject",
235
239
  name: pending.name,
236
240
  type: pending.mimeType,
237
241
  extension: ext,
238
- fileMode: "app",
242
+ bytes: pending.size,
243
+ folder: "livechat",
239
244
  },
240
245
  visitorQueryParams ? { params: visitorQueryParams } : undefined
241
- ) as any;
246
+ );
247
+ } catch {
248
+ throw new Error(`Failed to prepare upload for "${pending.name}"`);
249
+ }
242
250
 
243
- await fetch(presigned?.data?.signedUrl, {
251
+ const signedUrl = presigned?.data?.signedUrl;
252
+ if (!signedUrl) throw new Error(`No upload URL returned for "${pending.name}"`);
253
+
254
+ let putRes: Response;
255
+ try {
256
+ putRes = await fetch(signedUrl, {
244
257
  method: "PUT",
245
258
  headers: { "Content-Type": pending.mimeType },
246
259
  body: { uri: pending.uri, type: pending.mimeType, name: pending.name } as any,
247
260
  });
248
-
249
- const uploaded = presigned?.data?.file;
250
- return {
251
- id: pending.id,
252
- fileId: uploaded._id,
253
- fileUrl: uploaded.url,
254
- fileName: uploaded.name,
255
- fileExtension: uploaded.extension,
256
- fileType: pending.mimeType,
257
- type: pending.kind,
258
- };
259
261
  } catch {
260
- return null;
262
+ throw new Error(`Network error while uploading "${pending.name}"`);
263
+ }
264
+
265
+ if (!putRes.ok) {
266
+ throw new Error(`Upload failed for "${pending.name}" (HTTP ${putRes.status})`);
267
+ }
268
+
269
+ const uploaded = presigned?.data?.file;
270
+ if (!uploaded?._id) {
271
+ throw new Error(`Upload succeeded but server returned no file metadata for "${pending.name}"`);
261
272
  }
273
+
274
+ return {
275
+ id: pending.id,
276
+ fileId: uploaded._id,
277
+ fileUrl: uploaded.url,
278
+ fileName: uploaded.name,
279
+ fileExtension: uploaded.extension,
280
+ fileType: pending.mimeType,
281
+ type: pending.kind,
282
+ };
262
283
  }
263
284
 
264
285
  // ── main composer ─────────────────────────────────────────────────────────────
@@ -269,18 +290,26 @@ export function MessageComposer({
269
290
  placeholder = "Message…",
270
291
  }: MessageComposerProps) {
271
292
  const { theme: t, visitorQueryParams } = useLiveChatContext();
293
+ const { bottom: safeBottom } = usePanelSafeAreaInsets();
272
294
  const [text, setText] = useState("");
273
295
  const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
274
296
  const [isSending, setIsSending] = useState(false);
297
+ const [sendError, setSendError] = useState<string | null>(null);
275
298
  const inputRef = useRef<TextInput>(null);
276
299
 
300
+ const isConnecting = !visitorQueryParams;
277
301
  const trimmed = text.trim();
278
- const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending;
302
+ const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && !isSending && !isConnecting;
303
+
304
+ // Clear stale error when the user edits the message
305
+ useEffect(() => {
306
+ if (sendError && (text || attachments.length)) setSendError(null);
307
+ }, [text, attachments.length]);
279
308
 
280
309
  const handlePick = useCallback((item: PickedAttachment) => {
281
310
  setAttachments((prev) => {
282
311
  if (prev.length >= MAX_ATTACHMENTS) return prev;
283
- return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, kind: item.kind }];
312
+ return [...prev, { id: newId(), uri: item.uri, name: item.name, mimeType: item.type, size: item.size, kind: item.kind }];
284
313
  });
285
314
  }, []);
286
315
 
@@ -290,26 +319,61 @@ export function MessageComposer({
290
319
 
291
320
  const handleSend = useCallback(async () => {
292
321
  if (!canSend) return;
322
+
293
323
  setIsSending(true);
294
- const snapshot = [...attachments];
324
+ setSendError(null);
325
+
326
+ // Capture current values — text/attachments must NOT be cleared until send succeeds
295
327
  const sendText = text.trim();
328
+ const snapshot = [...attachments];
329
+
296
330
  try {
297
- const uploadResults = await Promise.all(
298
- snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
299
- );
300
- const uploaded = uploadResults.filter((r): r is UploadedAttachment => r !== null);
331
+ let uploaded: UploadedAttachment[] = [];
332
+
333
+ if (snapshot.length > 0) {
334
+ // Upload all attachments throws on the first failure, preserving draft
335
+ uploaded = await Promise.all(
336
+ snapshot.map((a) => uploadAttachment(a, visitorQueryParams))
337
+ );
338
+ }
339
+
340
+ // onSend must throw on failure (ConversationScreen propagates throws from useSendMessage)
301
341
  await onSend(sendText, uploaded);
342
+
343
+ // Only reached on confirmed success — safe to clear
302
344
  setText("");
303
345
  setAttachments([]);
304
- } catch {
305
- /* ignore */
346
+ setSendError(null);
347
+ } catch (e: any) {
348
+ // Draft preserved — text and attachments state untouched
349
+ setSendError(e?.message ?? "Failed to send. Please try again.");
306
350
  } finally {
307
351
  setIsSending(false);
308
352
  }
309
353
  }, [canSend, text, attachments, onSend, visitorQueryParams]);
310
354
 
311
355
  return (
312
- <View style={[styles.container, { backgroundColor: t.colors.composerBackground, borderTopColor: t.colors.composerBorder }]}>
356
+ <View
357
+ style={[
358
+ styles.container,
359
+ {
360
+ backgroundColor: t.colors.composerBackground,
361
+ borderTopColor: t.colors.composerBorder,
362
+ paddingBottom: 8 + safeBottom,
363
+ },
364
+ ]}
365
+ >
366
+ {sendError ? (
367
+ <View style={[styles.errorBanner, { backgroundColor: t.colors.surface, borderColor: t.colors.border }]}>
368
+ <Text style={[styles.errorText, { color: t.colors.error ?? "#c0392b" }]} numberOfLines={2}>
369
+ {sendError}
370
+ </Text>
371
+ <TouchableOpacity onPress={() => setSendError(null)} accessibilityLabel="Dismiss error">
372
+ <CloseIcon size={14} color={t.colors.textMuted} />
373
+ </TouchableOpacity>
374
+ </View>
375
+ ) : null}
376
+
313
377
  <View style={[styles.inner, { borderColor: t.colors.composerBorder }]}>
314
378
  <FilePreviews items={attachments} onRemove={handleRemove} borderColor={t.colors.border} />
315
379
 
@@ -318,11 +382,11 @@ export function MessageComposer({
318
382
  style={[styles.input, { color: t.colors.inputText, fontSize: t.fontSizes.md }]}
319
383
  value={text}
320
384
  onChangeText={setText}
321
- placeholder={placeholder}
385
+ placeholder={isConnecting ? "Connecting…" : placeholder}
322
386
  placeholderTextColor={t.colors.textMuted}
323
387
  multiline
324
388
  maxLength={4000}
325
- editable={!disabled}
389
+ editable={!disabled && !isConnecting}
326
390
  returnKeyType="default"
327
391
  blurOnSubmit={false}
328
392
  />
@@ -330,7 +394,7 @@ export function MessageComposer({
330
394
  <View style={styles.toolbar}>
331
395
  <AttachmentMenu
332
396
  onPick={handlePick}
333
- disabled={disabled || isSending}
397
+ disabled={disabled || isSending || isConnecting}
334
398
  attachmentCount={attachments.length}
335
399
  color={t.colors.textMuted}
336
400
  />
@@ -343,10 +407,12 @@ export function MessageComposer({
343
407
  styles.sendBtn,
344
408
  { backgroundColor: canSend ? t.colors.sendButtonBackground : t.colors.border },
345
409
  ]}
346
- accessibilityLabel="Send message"
410
+ accessibilityLabel={isConnecting ? "Connecting, please wait" : "Send message"}
347
411
  >
348
412
  {isSending ? (
349
413
  <ActivityIndicator size="small" color={t.colors.sendButtonIcon} />
414
+ ) : isConnecting ? (
415
+ <ActivityIndicator size="small" color={t.colors.textMuted} />
350
416
  ) : (
351
417
  <SendPlaneIcon size={18} color={canSend ? t.colors.sendButtonIcon : t.colors.textMuted} />
352
418
  )}
@@ -362,7 +428,6 @@ const styles = StyleSheet.create({
362
428
  borderTopWidth: 1,
363
429
  paddingHorizontal: 8,
364
430
  paddingTop: 8,
365
- paddingBottom: 8,
366
431
  },
367
432
  inner: {
368
433
  borderWidth: 1,
@@ -371,6 +436,21 @@ const styles = StyleSheet.create({
371
436
  paddingTop: 10,
372
437
  paddingBottom: 6,
373
438
  },
439
+ errorBanner: {
440
+ flexDirection: "row",
441
+ alignItems: "center",
442
+ justifyContent: "space-between",
443
+ borderWidth: 1,
444
+ borderRadius: 8,
445
+ paddingHorizontal: 12,
446
+ paddingVertical: 8,
447
+ marginBottom: 6,
448
+ gap: 8,
449
+ },
450
+ errorText: {
451
+ flex: 1,
452
+ fontSize: 13,
453
+ },
374
454
  input: {
375
455
  minHeight: 30,
376
456
  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
+ }